@qlover/oauth-wrapper 0.2.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/dist/index.cjs ADDED
@@ -0,0 +1,1043 @@
1
+ "use strict";
2
+ var __defProp = Object.defineProperty;
3
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
4
+ var __getOwnPropNames = Object.getOwnPropertyNames;
5
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
6
+ var __export = (target, all) => {
7
+ for (var name in all)
8
+ __defProp(target, name, { get: all[name], enumerable: true });
9
+ };
10
+ var __copyProps = (to, from, except, desc) => {
11
+ if (from && typeof from === "object" || typeof from === "function") {
12
+ for (let key of __getOwnPropNames(from))
13
+ if (!__hasOwnProp.call(to, key) && key !== except)
14
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
15
+ }
16
+ return to;
17
+ };
18
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
19
+
20
+ // src/server/index.ts
21
+ var server_exports = {};
22
+ __export(server_exports, {
23
+ OAuthAuthorizationCodeRowSchema: () => OAuthAuthorizationCodeRowSchema,
24
+ OAuthAuthorizeQuerySchema: () => OAuthAuthorizeQuerySchema,
25
+ OAuthClientCreateResponseSchema: () => OAuthClientCreateResponseSchema,
26
+ OAuthClientCreateSchema: () => OAuthClientCreateSchema,
27
+ OAuthClientDetailSchema: () => OAuthClientDetailSchema,
28
+ OAuthClientListItemSchema: () => OAuthClientListItemSchema,
29
+ OAuthClientRowSchema: () => OAuthClientRowSchema,
30
+ OAuthClientSecretRotateResponseSchema: () => OAuthClientSecretRotateResponseSchema,
31
+ OAuthClientUpdateSchema: () => OAuthClientUpdateSchema,
32
+ OAuthClientsService: () => OAuthClientsService,
33
+ OAuthConsentBodySchema: () => OAuthConsentBodySchema,
34
+ OAuthRefreshTokenSchema: () => OAuthRefreshTokenSchema,
35
+ OAuthRfcCodes: () => OAuthRfcCodes,
36
+ OAuthTokenAuthorizationCodeSchema: () => OAuthTokenAuthorizationCodeSchema,
37
+ OAuthTokenErrorResponseSchema: () => OAuthTokenErrorResponseSchema,
38
+ OAuthTokenRefreshSchema: () => OAuthTokenRefreshSchema,
39
+ OAuthTokenRequestSchema: () => OAuthTokenRequestSchema,
40
+ OAuthTokenResponseSchema: () => OAuthTokenResponseSchema,
41
+ OAuthTokenRevokeSchema: () => OAuthTokenRevokeSchema,
42
+ OAuthTokenService: () => OAuthTokenService,
43
+ OAuthUserCredentialsSchema: () => OAuthUserCredentialsSchema,
44
+ OAuthUserInfoErrorResponseSchema: () => OAuthUserInfoErrorResponseSchema,
45
+ OAuthUserInfoResponseSchema: () => OAuthUserInfoResponseSchema,
46
+ OAuthWrapperError: () => OAuthWrapperError,
47
+ OAuthWrapperService: () => OAuthWrapperService,
48
+ buildOAuthRedirectUrl: () => buildOAuthRedirectUrl,
49
+ computePkceS256Challenge: () => computePkceS256Challenge,
50
+ computeS256CodeChallenge: () => computeS256CodeChallenge,
51
+ generatePkceVerifier: () => generatePkceVerifier,
52
+ hashClientSecret: () => hashClientSecret,
53
+ isOAuthRedirectUri: () => isOAuthRedirectUri,
54
+ isRedirectUriAllowed: () => isRedirectUriAllowed,
55
+ isValidCodeChallenge: () => isValidCodeChallenge,
56
+ isValidCodeVerifier: () => isValidCodeVerifier,
57
+ normalizeQuery: () => normalizeQuery,
58
+ parseScopeList: () => parseScopeList,
59
+ randomOAuthState: () => randomOAuthState,
60
+ validatePkceParams: () => validatePkceParams,
61
+ verifyClientSecret: () => verifyClientSecret,
62
+ verifyPkceS256: () => verifyPkceS256
63
+ });
64
+ module.exports = __toCommonJS(server_exports);
65
+
66
+ // src/server/services/OAuthClientsService.ts
67
+ var OAuthClientsService = class {
68
+ constructor(clientsRepo) {
69
+ this.clientsRepo = clientsRepo;
70
+ }
71
+ /**
72
+ * List all OAuth clients owned by a user
73
+ * @override
74
+ */
75
+ async listForOwner(ownerUserId) {
76
+ return this.clientsRepo.listClientByOwner(ownerUserId);
77
+ }
78
+ /**
79
+ * Get detailed information about a specific OAuth client
80
+ * @override
81
+ */
82
+ async getByClientId(ownerUserId, clientId) {
83
+ const client = await this.clientsRepo.findClientById(clientId);
84
+ if (!client) {
85
+ throw new Error("Client not found");
86
+ }
87
+ if (client.owner_user_id !== ownerUserId) {
88
+ throw new Error("Access denied");
89
+ }
90
+ return this.mapToDetail(client);
91
+ }
92
+ /**
93
+ * Create a new OAuth client
94
+ * @override
95
+ */
96
+ async create(ownerUserId, input) {
97
+ const result = await this.clientsRepo.createClient(ownerUserId, input);
98
+ return {
99
+ client_id: result.client.client_id,
100
+ client_secret: result.clientSecret,
101
+ confidential: result.client.confidential,
102
+ client_name: result.client.client_name,
103
+ client_uri: result.client.client_uri,
104
+ redirect_uris: result.client.redirect_uris,
105
+ created_at: result.client.created_at
106
+ };
107
+ }
108
+ /**
109
+ * Update an existing OAuth client
110
+ * @override
111
+ */
112
+ async update(ownerUserId, clientId, input) {
113
+ const existing = await this.clientsRepo.findClientById(clientId);
114
+ if (!existing) {
115
+ throw new Error("Client not found");
116
+ }
117
+ if (existing.owner_user_id !== ownerUserId) {
118
+ throw new Error("Access denied");
119
+ }
120
+ return this.clientsRepo.updateClient(ownerUserId, clientId, input);
121
+ }
122
+ /**
123
+ * Rotate the client secret
124
+ * @override
125
+ */
126
+ async rotateSecret(ownerUserId, clientId) {
127
+ const existing = await this.clientsRepo.findClientById(clientId);
128
+ if (!existing) {
129
+ throw new Error("Client not found");
130
+ }
131
+ if (existing.owner_user_id !== ownerUserId) {
132
+ throw new Error("Access denied");
133
+ }
134
+ if (!existing.confidential) {
135
+ throw new Error("Public clients do not have a client_secret");
136
+ }
137
+ const result = await this.clientsRepo.rotateClientSecret(
138
+ ownerUserId,
139
+ clientId
140
+ );
141
+ return {
142
+ client_id: clientId,
143
+ client_secret: result.clientSecret
144
+ };
145
+ }
146
+ /**
147
+ * Delete an OAuth client
148
+ * @override
149
+ */
150
+ async delete(ownerUserId, clientId) {
151
+ const existing = await this.clientsRepo.findClientById(clientId);
152
+ if (!existing) {
153
+ throw new Error("Client not found");
154
+ }
155
+ if (existing.owner_user_id !== ownerUserId) {
156
+ throw new Error("Access denied");
157
+ }
158
+ await this.clientsRepo.deleteClient(ownerUserId, clientId);
159
+ }
160
+ mapToDetail(row) {
161
+ return {
162
+ client_id: row.client_id,
163
+ client_name: row.client_name,
164
+ client_uri: row.client_uri,
165
+ logo_uri: row.logo_uri,
166
+ redirect_uris: row.redirect_uris,
167
+ grant_types: row.grant_types,
168
+ scopes: row.scopes,
169
+ confidential: row.confidential,
170
+ created_at: row.created_at,
171
+ updated_at: row.updated_at
172
+ };
173
+ }
174
+ };
175
+
176
+ // src/server/services/OAuthTokenService.ts
177
+ var import_crypto2 = require("crypto");
178
+
179
+ // src/core/schema/OAuthAuthorizeSchema.ts
180
+ var import_zod = require("zod");
181
+ function isOAuthRedirectUri(value) {
182
+ const trimmed = value.trim();
183
+ if (!trimmed) {
184
+ return false;
185
+ }
186
+ try {
187
+ const parsed = new URL(trimmed);
188
+ return Boolean(parsed.protocol && parsed.protocol.endsWith(":"));
189
+ } catch {
190
+ return /^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(trimmed);
191
+ }
192
+ }
193
+ var oauthRedirectUriSchema = import_zod.z.string().min(1).refine(isOAuthRedirectUri, { message: "Invalid redirect URI" });
194
+ var OAuthClientRowSchema = import_zod.z.object({
195
+ id: import_zod.z.number(),
196
+ client_id: import_zod.z.string(),
197
+ client_secret_hash: import_zod.z.string().nullable().optional(),
198
+ client_name: import_zod.z.string(),
199
+ client_uri: import_zod.z.string().nullable().optional(),
200
+ logo_uri: import_zod.z.string().nullable().optional(),
201
+ redirect_uris: import_zod.z.array(import_zod.z.string()),
202
+ grant_types: import_zod.z.array(import_zod.z.string()),
203
+ scopes: import_zod.z.array(import_zod.z.string()),
204
+ confidential: import_zod.z.boolean(),
205
+ owner_user_id: import_zod.z.number(),
206
+ created_at: import_zod.z.string(),
207
+ updated_at: import_zod.z.string()
208
+ });
209
+ var OAuthClientListItemSchema = import_zod.z.object({
210
+ client_id: import_zod.z.string(),
211
+ client_name: import_zod.z.string(),
212
+ client_uri: import_zod.z.string().nullable().optional(),
213
+ logo_uri: import_zod.z.string().nullable().optional(),
214
+ redirect_uris: import_zod.z.array(import_zod.z.string()),
215
+ confidential: import_zod.z.boolean(),
216
+ created_at: import_zod.z.string(),
217
+ updated_at: import_zod.z.string()
218
+ });
219
+ var OAuthClientDetailSchema = import_zod.z.object({
220
+ client_id: import_zod.z.string(),
221
+ client_name: import_zod.z.string(),
222
+ client_uri: import_zod.z.string().nullable().optional(),
223
+ logo_uri: import_zod.z.string().nullable().optional(),
224
+ redirect_uris: import_zod.z.array(import_zod.z.string()),
225
+ grant_types: import_zod.z.array(import_zod.z.string()),
226
+ scopes: import_zod.z.array(import_zod.z.string()),
227
+ confidential: import_zod.z.boolean(),
228
+ created_at: import_zod.z.string(),
229
+ updated_at: import_zod.z.string()
230
+ });
231
+ var OAuthClientCreateSchema = import_zod.z.object({
232
+ client_name: import_zod.z.string().min(1).max(100),
233
+ client_uri: import_zod.z.string().url().optional().or(import_zod.z.literal("")),
234
+ redirect_uris: import_zod.z.array(oauthRedirectUriSchema).min(1),
235
+ /** `true` = confidential (client_secret); `false` = public (PKCE required). */
236
+ confidential: import_zod.z.boolean().default(true)
237
+ });
238
+ var OAuthClientUpdateSchema = import_zod.z.object({
239
+ client_name: import_zod.z.string().min(1).max(100),
240
+ client_uri: import_zod.z.string().url().optional().or(import_zod.z.literal("")),
241
+ redirect_uris: import_zod.z.array(oauthRedirectUriSchema).min(1)
242
+ });
243
+ var OAuthClientCreateResponseSchema = import_zod.z.object({
244
+ client_id: import_zod.z.string(),
245
+ client_secret: import_zod.z.string().optional(),
246
+ confidential: import_zod.z.boolean(),
247
+ client_name: import_zod.z.string(),
248
+ client_uri: import_zod.z.string().nullable().optional(),
249
+ redirect_uris: import_zod.z.array(import_zod.z.string()),
250
+ created_at: import_zod.z.string()
251
+ });
252
+ var OAuthClientSecretRotateResponseSchema = import_zod.z.object({
253
+ client_id: import_zod.z.string(),
254
+ client_secret: import_zod.z.string()
255
+ });
256
+ var OAuthAuthorizeQuerySchema = import_zod.z.object({
257
+ response_type: import_zod.z.literal("code"),
258
+ client_id: import_zod.z.string().min(1),
259
+ redirect_uri: oauthRedirectUriSchema,
260
+ scope: import_zod.z.string().optional(),
261
+ state: import_zod.z.string().optional(),
262
+ code_challenge: import_zod.z.string().min(43).max(128).optional(),
263
+ code_challenge_method: import_zod.z.literal("S256").optional()
264
+ });
265
+ var OAuthConsentBodySchema = import_zod.z.object({
266
+ action: import_zod.z.enum(["allow", "deny"]),
267
+ client_id: import_zod.z.string().min(1),
268
+ redirect_uri: oauthRedirectUriSchema,
269
+ scope: import_zod.z.string().optional(),
270
+ state: import_zod.z.string().optional(),
271
+ trust: import_zod.z.boolean().optional(),
272
+ code_challenge: import_zod.z.string().min(43).max(128).optional(),
273
+ code_challenge_method: import_zod.z.literal("S256").optional()
274
+ });
275
+ var OAuthAuthorizationCodeRowSchema = import_zod.z.object({
276
+ code: import_zod.z.string(),
277
+ client_id: import_zod.z.string(),
278
+ user_id: import_zod.z.number(),
279
+ redirect_uri: import_zod.z.string(),
280
+ scope: import_zod.z.string().nullable().optional(),
281
+ code_challenge: import_zod.z.string().nullable().optional(),
282
+ code_challenge_method: import_zod.z.string().nullable().optional(),
283
+ expires_at: import_zod.z.string(),
284
+ used: import_zod.z.boolean(),
285
+ created_at: import_zod.z.string()
286
+ });
287
+
288
+ // src/core/schema/OAuthClientSchema.ts
289
+ var import_zod2 = require("zod");
290
+ var OAuthUserCredentialsSchema = import_zod2.z.object({
291
+ user_id: import_zod2.z.number(),
292
+ provider_refresh_token: import_zod2.z.string().nullable().optional(),
293
+ provider_session_token: import_zod2.z.string().nullable().optional(),
294
+ updated_at: import_zod2.z.string()
295
+ });
296
+ var OAuthRefreshTokenSchema = import_zod2.z.object({
297
+ id: import_zod2.z.number(),
298
+ refresh_token: import_zod2.z.string(),
299
+ client_id: import_zod2.z.string(),
300
+ user_id: import_zod2.z.number(),
301
+ expires_at: import_zod2.z.string(),
302
+ revoked: import_zod2.z.boolean(),
303
+ created_at: import_zod2.z.string()
304
+ });
305
+ var OAuthTokenResponseSchema = import_zod2.z.object({
306
+ access_token: import_zod2.z.string(),
307
+ token_type: import_zod2.z.literal("Bearer"),
308
+ expires_in: import_zod2.z.number(),
309
+ refresh_token: import_zod2.z.string().optional(),
310
+ scope: import_zod2.z.string().optional()
311
+ });
312
+
313
+ // src/core/schema/OAuthUserInfoSchema.ts
314
+ var import_zod3 = require("zod");
315
+ var OAuthUserInfoResponseSchema = import_zod3.z.object({
316
+ sub: import_zod3.z.string(),
317
+ email: import_zod3.z.string().email(),
318
+ name: import_zod3.z.string(),
319
+ roles: import_zod3.z.array(import_zod3.z.string()).optional()
320
+ });
321
+ var OAuthUserInfoErrorResponseSchema = import_zod3.z.object({
322
+ error: import_zod3.z.literal("invalid_token"),
323
+ error_id: import_zod3.z.string().optional()
324
+ });
325
+
326
+ // src/core/schema/OAuthTokenSchema.ts
327
+ var import_zod4 = require("zod");
328
+ var OAuthTokenAuthorizationCodeSchema = import_zod4.z.object({
329
+ grant_type: import_zod4.z.literal("authorization_code"),
330
+ code: import_zod4.z.string().min(1),
331
+ redirect_uri: import_zod4.z.string().min(1),
332
+ client_id: import_zod4.z.string().min(1),
333
+ client_secret: import_zod4.z.string().min(1).optional(),
334
+ code_verifier: import_zod4.z.string().min(43).max(128).optional()
335
+ });
336
+ var OAuthTokenRefreshSchema = import_zod4.z.object({
337
+ grant_type: import_zod4.z.literal("refresh_token"),
338
+ refresh_token: import_zod4.z.string().min(1),
339
+ client_id: import_zod4.z.string().min(1),
340
+ client_secret: import_zod4.z.string().min(1).optional()
341
+ });
342
+ var OAuthTokenRequestSchema = import_zod4.z.discriminatedUnion("grant_type", [
343
+ OAuthTokenAuthorizationCodeSchema,
344
+ OAuthTokenRefreshSchema
345
+ ]);
346
+ var OAuthTokenErrorResponseSchema = import_zod4.z.object({
347
+ error: import_zod4.z.string(),
348
+ error_id: import_zod4.z.string().optional(),
349
+ error_description: import_zod4.z.string().optional()
350
+ });
351
+ var OAuthTokenRevokeSchema = import_zod4.z.object({
352
+ token: import_zod4.z.string().min(1),
353
+ token_type_hint: import_zod4.z.enum(["access_token", "refresh_token"]).optional(),
354
+ client_id: import_zod4.z.string().min(1),
355
+ client_secret: import_zod4.z.string().min(1).optional()
356
+ });
357
+
358
+ // src/core/config.ts
359
+ var OAuthRfcCodes = {
360
+ INVALID_REQUEST: "invalid_request",
361
+ INVALID_CLIENT: "invalid_client",
362
+ INVALID_GRANT: "invalid_grant",
363
+ INVALID_TOKEN: "invalid_token",
364
+ UNAUTHORIZED_CLIENT: "unauthorized_client",
365
+ INVALID_SCOPE: "invalid_scope",
366
+ ACCESS_DENIED: "access_denied",
367
+ UNSUPPORTED_RESPONSE_TYPE: "unsupported_response_type",
368
+ UNSUPPORTED_GRANT_TYPE: "unsupported_grant_type",
369
+ OAUTH_ERROR: "oauth_error"
370
+ };
371
+
372
+ // src/core/utils/pkce.ts
373
+ var PKCE_CHARSET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~";
374
+ function generatePkceVerifier(length = 64) {
375
+ const size = Math.min(128, Math.max(43, length));
376
+ const values = new Uint8Array(size);
377
+ crypto.getRandomValues(values);
378
+ let result = "";
379
+ for (let i = 0; i < size; i++) {
380
+ result += PKCE_CHARSET[values[i] % PKCE_CHARSET.length];
381
+ }
382
+ return result;
383
+ }
384
+ function base64UrlEncode(buffer) {
385
+ const bytes = new Uint8Array(buffer);
386
+ let binary = "";
387
+ for (const byte of bytes) {
388
+ binary += String.fromCharCode(byte);
389
+ }
390
+ return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
391
+ }
392
+ async function computePkceS256Challenge(verifier) {
393
+ const data = new TextEncoder().encode(verifier);
394
+ const digest = await crypto.subtle.digest("SHA-256", data);
395
+ return base64UrlEncode(digest);
396
+ }
397
+ function randomOAuthState() {
398
+ const bytes = new Uint8Array(16);
399
+ crypto.getRandomValues(bytes);
400
+ return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
401
+ }
402
+
403
+ // src/server/utils/OAuthWrapperError.ts
404
+ var import_fe_corekit = require("@qlover/fe-corekit");
405
+ var OAuthWrapperError = class extends import_fe_corekit.ExecutorError {
406
+ constructor(id, status, cause) {
407
+ super(id, cause);
408
+ this.status = status;
409
+ }
410
+ name = "OAuthWrapperError";
411
+ };
412
+
413
+ // src/server/utils/pkce.ts
414
+ var import_crypto = require("crypto");
415
+ var PKCE_VERIFIER_MIN = 43;
416
+ var PKCE_VERIFIER_MAX = 128;
417
+ var UNRESERVED = /^[A-Za-z0-9\-._~]+$/;
418
+ function isValidCodeVerifier(verifier) {
419
+ const len = verifier.length;
420
+ if (len < PKCE_VERIFIER_MIN || len > PKCE_VERIFIER_MAX) {
421
+ return false;
422
+ }
423
+ return UNRESERVED.test(verifier);
424
+ }
425
+ function isValidCodeChallenge(challenge) {
426
+ if (challenge.length < PKCE_VERIFIER_MIN || challenge.length > PKCE_VERIFIER_MAX) {
427
+ return false;
428
+ }
429
+ return UNRESERVED.test(challenge);
430
+ }
431
+ function computeS256CodeChallenge(verifier) {
432
+ return (0, import_crypto.createHash)("sha256").update(verifier).digest("base64url");
433
+ }
434
+ function verifyPkceS256(verifier, challenge) {
435
+ if (!isValidCodeVerifier(verifier) || !isValidCodeChallenge(challenge)) {
436
+ return false;
437
+ }
438
+ const computed = computeS256CodeChallenge(verifier);
439
+ try {
440
+ return (0, import_crypto.timingSafeEqual)(Buffer.from(computed), Buffer.from(challenge));
441
+ } catch {
442
+ return false;
443
+ }
444
+ }
445
+
446
+ // src/server/services/OAuthTokenService.ts
447
+ function hashOpaqueToken(token) {
448
+ return (0, import_crypto2.createHash)("sha256").update(token).digest("hex");
449
+ }
450
+ var OAuthTokenService = class _OAuthTokenService {
451
+ constructor(tokenEncryption, userAdapter, oauthRepo) {
452
+ this.tokenEncryption = tokenEncryption;
453
+ this.userAdapter = userAdapter;
454
+ this.oauthRepo = oauthRepo;
455
+ }
456
+ static REFRESH_TOKEN_TTL_MS = 90 * 24 * 60 * 60 * 1e3;
457
+ /**
458
+ * @override
459
+ */
460
+ async exchangeToken(rawFields) {
461
+ let request;
462
+ try {
463
+ request = OAuthTokenRequestSchema.parse(rawFields);
464
+ } catch {
465
+ throw new OAuthWrapperError(
466
+ OAuthRfcCodes.INVALID_REQUEST,
467
+ 400,
468
+ "Malformed token request"
469
+ );
470
+ }
471
+ let client;
472
+ try {
473
+ client = await this.oauthRepo.verifyClientCredentials(
474
+ request.client_id,
475
+ request.client_secret
476
+ );
477
+ } catch {
478
+ throw new OAuthWrapperError(OAuthRfcCodes.INVALID_CLIENT, 401);
479
+ }
480
+ if (!client.grant_types.includes(request.grant_type)) {
481
+ throw new OAuthWrapperError(OAuthRfcCodes.UNSUPPORTED_GRANT_TYPE, 400);
482
+ }
483
+ if (request.grant_type === "authorization_code") {
484
+ return await this.exchangeAuthorizationCode(request, client.client_id);
485
+ }
486
+ return await this.exchangeRefreshToken(request, client.client_id);
487
+ }
488
+ async exchangeAuthorizationCode(request, verifiedClientId) {
489
+ const authCode = await this.oauthRepo.consumeCode(request.code);
490
+ if (!authCode) {
491
+ throw new OAuthWrapperError(
492
+ OAuthRfcCodes.INVALID_GRANT,
493
+ 400,
494
+ "Authorization code is invalid, expired, or already used"
495
+ );
496
+ }
497
+ if (authCode.client_id !== verifiedClientId) {
498
+ throw new OAuthWrapperError(
499
+ OAuthRfcCodes.INVALID_GRANT,
500
+ 400,
501
+ "client_id mismatch"
502
+ );
503
+ }
504
+ if (authCode.redirect_uri !== request.redirect_uri) {
505
+ throw new OAuthWrapperError(
506
+ OAuthRfcCodes.INVALID_GRANT,
507
+ 400,
508
+ "redirect_uri mismatch"
509
+ );
510
+ }
511
+ this.assertPkceForAuthorizationCode(request, authCode);
512
+ const providerTokens = await this.fetchProviderAccessToken(
513
+ authCode.user_id
514
+ );
515
+ const middlewareRefresh = await this.issueMiddlewareRefreshToken(
516
+ authCode.client_id,
517
+ authCode.user_id
518
+ );
519
+ return {
520
+ access_token: providerTokens.access_token,
521
+ token_type: "Bearer",
522
+ expires_in: providerTokens.expires_in,
523
+ refresh_token: middlewareRefresh,
524
+ scope: authCode.scope ?? void 0
525
+ };
526
+ }
527
+ assertPkceForAuthorizationCode(request, authCode) {
528
+ const storedChallenge = authCode.code_challenge?.trim();
529
+ if (storedChallenge) {
530
+ const verifier = request.code_verifier?.trim();
531
+ if (!verifier) {
532
+ throw new OAuthWrapperError(
533
+ OAuthRfcCodes.INVALID_GRANT,
534
+ 400,
535
+ "code_verifier is required"
536
+ );
537
+ }
538
+ if (authCode.code_challenge_method !== "S256") {
539
+ throw new OAuthWrapperError(
540
+ OAuthRfcCodes.INVALID_GRANT,
541
+ 400,
542
+ "Unsupported PKCE method"
543
+ );
544
+ }
545
+ if (!verifyPkceS256(verifier, storedChallenge)) {
546
+ throw new OAuthWrapperError(
547
+ OAuthRfcCodes.INVALID_GRANT,
548
+ 400,
549
+ "code_verifier mismatch"
550
+ );
551
+ }
552
+ return;
553
+ }
554
+ if (!request.client_secret?.trim()) {
555
+ throw new OAuthWrapperError(
556
+ OAuthRfcCodes.INVALID_CLIENT,
557
+ 401,
558
+ "client_secret is required when PKCE is not used"
559
+ );
560
+ }
561
+ }
562
+ async exchangeRefreshToken(request, verifiedClientId) {
563
+ const tokenHash = hashOpaqueToken(request.refresh_token);
564
+ const stored = await this.oauthRepo.findByTokenHash(tokenHash);
565
+ if (!stored || stored.revoked || stored.client_id !== verifiedClientId || new Date(stored.expires_at) <= /* @__PURE__ */ new Date()) {
566
+ throw new OAuthWrapperError(
567
+ OAuthRfcCodes.INVALID_GRANT,
568
+ 400,
569
+ "Refresh token is invalid"
570
+ );
571
+ }
572
+ const providerTokens = await this.fetchProviderAccessToken(stored.user_id);
573
+ await this.oauthRepo.revokeByTokenHash(tokenHash);
574
+ const middlewareRefresh = await this.issueMiddlewareRefreshToken(
575
+ stored.client_id,
576
+ stored.user_id
577
+ );
578
+ return {
579
+ access_token: providerTokens.access_token,
580
+ token_type: "Bearer",
581
+ expires_in: providerTokens.expires_in,
582
+ refresh_token: middlewareRefresh
583
+ };
584
+ }
585
+ async fetchProviderAccessToken(userId) {
586
+ const credentials = await this.oauthRepo.getUserCredentials(userId);
587
+ const sessionToken = credentials?.provider_session_token?.trim();
588
+ if (!sessionToken) {
589
+ throw new OAuthWrapperError(
590
+ OAuthRfcCodes.INVALID_GRANT,
591
+ 400,
592
+ "User credentials expired. Re-authorization required."
593
+ );
594
+ }
595
+ try {
596
+ const access = await this.userAdapter.exchangeAccessToken({
597
+ token: sessionToken
598
+ });
599
+ if (access.refresh_token) {
600
+ await this.oauthRepo.upsertUserCredentials(userId, {
601
+ provider_refresh_token: this.tokenEncryption.encrypt(
602
+ access.refresh_token
603
+ )
604
+ });
605
+ }
606
+ return {
607
+ access_token: access.access_token,
608
+ expires_in: access.expires_in
609
+ };
610
+ } catch {
611
+ throw new OAuthWrapperError(
612
+ OAuthRfcCodes.INVALID_GRANT,
613
+ 400,
614
+ "Failed to obtain access token from user provider"
615
+ );
616
+ }
617
+ }
618
+ async issueMiddlewareRefreshToken(clientId, userId) {
619
+ const plain = (0, import_crypto2.randomBytes)(32).toString("base64url");
620
+ const expiresAt = new Date(
621
+ Date.now() + _OAuthTokenService.REFRESH_TOKEN_TTL_MS
622
+ ).toISOString();
623
+ await this.oauthRepo.createRefreshToken({
624
+ refresh_token: hashOpaqueToken(plain),
625
+ client_id: clientId,
626
+ user_id: userId,
627
+ expires_at: expiresAt
628
+ });
629
+ return plain;
630
+ }
631
+ /**
632
+ * @override
633
+ * RFC 7009 — revoke middleware refresh tokens. Idempotent: unknown tokens are ignored.
634
+ */
635
+ async revokeToken(rawFields) {
636
+ let request;
637
+ try {
638
+ request = OAuthTokenRevokeSchema.parse(rawFields);
639
+ } catch {
640
+ throw new OAuthWrapperError(
641
+ OAuthRfcCodes.INVALID_REQUEST,
642
+ 400,
643
+ "Malformed revocation request"
644
+ );
645
+ }
646
+ try {
647
+ await this.oauthRepo.verifyClientCredentials(
648
+ request.client_id,
649
+ request.client_secret
650
+ );
651
+ } catch {
652
+ throw new OAuthWrapperError(OAuthRfcCodes.INVALID_CLIENT, 401);
653
+ }
654
+ if (request.token_type_hint && request.token_type_hint !== "refresh_token") {
655
+ return;
656
+ }
657
+ const tokenHash = hashOpaqueToken(request.token);
658
+ const stored = await this.oauthRepo.findByTokenHash(tokenHash);
659
+ if (!stored || stored.client_id !== request.client_id) {
660
+ return;
661
+ }
662
+ await this.oauthRepo.revokeByTokenHash(tokenHash);
663
+ }
664
+ };
665
+
666
+ // src/server/services/OAuthWrapperService.ts
667
+ var import_crypto3 = require("crypto");
668
+ var import_fe_corekit2 = require("@qlover/fe-corekit");
669
+
670
+ // src/server/utils/authorizeUtil.ts
671
+ function validatePkceParams(parsed, confidential) {
672
+ const hasChallenge = Boolean(parsed.code_challenge?.trim());
673
+ const hasMethod = Boolean(parsed.code_challenge_method);
674
+ if (!confidential) {
675
+ if (!hasChallenge || parsed.code_challenge_method !== "S256") {
676
+ return {
677
+ errorKey: OAuthRfcCodes.INVALID_REQUEST,
678
+ message: "Public clients must send code_challenge and code_challenge_method=S256."
679
+ };
680
+ }
681
+ if (!isValidCodeChallenge(parsed.code_challenge)) {
682
+ return {
683
+ errorKey: OAuthRfcCodes.INVALID_REQUEST,
684
+ message: "Invalid code_challenge."
685
+ };
686
+ }
687
+ return null;
688
+ }
689
+ if (hasChallenge !== hasMethod) {
690
+ return {
691
+ errorKey: OAuthRfcCodes.INVALID_REQUEST,
692
+ message: "code_challenge and code_challenge_method must be sent together."
693
+ };
694
+ }
695
+ if (hasChallenge) {
696
+ if (parsed.code_challenge_method !== "S256") {
697
+ return {
698
+ errorKey: OAuthRfcCodes.INVALID_REQUEST,
699
+ message: "Only code_challenge_method=S256 is supported."
700
+ };
701
+ }
702
+ if (!isValidCodeChallenge(parsed.code_challenge)) {
703
+ return {
704
+ errorKey: OAuthRfcCodes.INVALID_REQUEST,
705
+ message: "Invalid code_challenge."
706
+ };
707
+ }
708
+ }
709
+ return null;
710
+ }
711
+ function normalizeQuery(raw) {
712
+ const result = {};
713
+ for (const [key, value] of Object.entries(raw)) {
714
+ if (Array.isArray(value)) {
715
+ result[key] = value[0];
716
+ } else {
717
+ result[key] = value;
718
+ }
719
+ }
720
+ if (!result.response_type) {
721
+ result.response_type = "code";
722
+ }
723
+ return result;
724
+ }
725
+ function isRedirectUriAllowed(redirectUri, client) {
726
+ return client.redirect_uris.includes(redirectUri);
727
+ }
728
+
729
+ // src/server/utils/oauthRedirectUtils.ts
730
+ function buildOAuthRedirectUrl(redirectUri, params) {
731
+ const url = new URL(redirectUri);
732
+ for (const [key, value] of Object.entries(params)) {
733
+ if (value != null && value !== "") {
734
+ url.searchParams.set(key, value);
735
+ }
736
+ }
737
+ return url.toString();
738
+ }
739
+ function parseScopeList(scope) {
740
+ if (!scope?.trim()) {
741
+ return ["openid", "profile", "email"];
742
+ }
743
+ return scope.trim().split(/\s+/).filter(Boolean);
744
+ }
745
+
746
+ // src/server/services/OAuthWrapperService.ts
747
+ var AUTH_CODE_TTL_MS = 5 * 60 * 1e3;
748
+ var OAuthWrapperService = class {
749
+ constructor(oauthSession, userAdapter, tokenService, oauthRepo) {
750
+ this.oauthSession = oauthSession;
751
+ this.userAdapter = userAdapter;
752
+ this.tokenService = tokenService;
753
+ this.oauthRepo = oauthRepo;
754
+ }
755
+ /**
756
+ * @override
757
+ */
758
+ getOAuthSession() {
759
+ return this.oauthSession;
760
+ }
761
+ /**
762
+ * @override
763
+ */
764
+ getOAuthAdapter() {
765
+ return this.userAdapter;
766
+ }
767
+ /**
768
+ * @override
769
+ */
770
+ getOAuthTokenService() {
771
+ return this.tokenService;
772
+ }
773
+ /**
774
+ * @override
775
+ */
776
+ getOAuthRepo() {
777
+ return this.oauthRepo;
778
+ }
779
+ isQuery(query) {
780
+ return OAuthAuthorizeQuerySchema.safeParse(query).success;
781
+ }
782
+ isValidateConsent(value) {
783
+ return OAuthConsentBodySchema.safeParse(value).success;
784
+ }
785
+ /**
786
+ * @override
787
+ */
788
+ async resolveAuthorizePage(rawQuery) {
789
+ const query = normalizeQuery(rawQuery);
790
+ if (!this.isQuery(query)) {
791
+ return {
792
+ ok: false,
793
+ error: {
794
+ errorKey: OAuthRfcCodes.INVALID_REQUEST,
795
+ message: "Missing or invalid authorization request parameters."
796
+ }
797
+ };
798
+ }
799
+ if (query.response_type !== "code") {
800
+ return {
801
+ ok: false,
802
+ error: {
803
+ errorKey: OAuthRfcCodes.UNSUPPORTED_RESPONSE_TYPE,
804
+ message: "Only response_type=code is supported."
805
+ }
806
+ };
807
+ }
808
+ const client = await this.oauthRepo.findClientById(query.client_id);
809
+ if (!client) {
810
+ return {
811
+ ok: false,
812
+ error: {
813
+ errorKey: OAuthRfcCodes.UNAUTHORIZED_CLIENT,
814
+ message: "Unknown client_id."
815
+ }
816
+ };
817
+ }
818
+ if (!isRedirectUriAllowed(query.redirect_uri, client)) {
819
+ return {
820
+ ok: false,
821
+ error: {
822
+ errorKey: OAuthRfcCodes.UNAUTHORIZED_CLIENT,
823
+ message: "redirect_uri is not registered for this client."
824
+ }
825
+ };
826
+ }
827
+ const requestedScopes = parseScopeList(query.scope);
828
+ const invalidScope = requestedScopes.find(
829
+ (scope) => !client.scopes.includes(scope)
830
+ );
831
+ if (invalidScope) {
832
+ return {
833
+ ok: false,
834
+ error: {
835
+ errorKey: OAuthRfcCodes.INVALID_SCOPE,
836
+ message: `Scope "${invalidScope}" is not allowed for this client.`
837
+ }
838
+ };
839
+ }
840
+ const pkceError = validatePkceParams(query, client.confidential);
841
+ if (pkceError) {
842
+ return { ok: false, error: pkceError };
843
+ }
844
+ return {
845
+ ok: true,
846
+ data: {
847
+ clientId: client.client_id,
848
+ clientName: client.client_name,
849
+ clientUri: client.client_uri ?? null,
850
+ logoUri: client.logo_uri ?? null,
851
+ redirectUri: query.redirect_uri,
852
+ scopes: requestedScopes,
853
+ state: query.state,
854
+ responseType: "code",
855
+ codeChallenge: query.code_challenge,
856
+ codeChallengeMethod: query.code_challenge_method,
857
+ confidential: client.confidential
858
+ }
859
+ };
860
+ }
861
+ /**
862
+ * @override
863
+ */
864
+ async processConsent(requestBody) {
865
+ if (!this.isValidateConsent(requestBody)) {
866
+ throw new import_fe_corekit2.ExecutorError(
867
+ OAuthRfcCodes.INVALID_REQUEST,
868
+ "Invalid consent request body"
869
+ );
870
+ }
871
+ const session = await this.oauthSession.getSession();
872
+ if (!session) {
873
+ throw new import_fe_corekit2.ExecutorError(
874
+ OAuthRfcCodes.ACCESS_DENIED,
875
+ "User session expired. Please sign in again."
876
+ );
877
+ }
878
+ const pageResult = await this.resolveAuthorizePage({
879
+ response_type: "code",
880
+ client_id: requestBody.client_id,
881
+ redirect_uri: requestBody.redirect_uri,
882
+ scope: requestBody.scope,
883
+ state: requestBody.state,
884
+ code_challenge: requestBody.code_challenge,
885
+ code_challenge_method: requestBody.code_challenge_method
886
+ });
887
+ if (!pageResult.ok) {
888
+ throw new import_fe_corekit2.ExecutorError(
889
+ pageResult.error.errorKey,
890
+ pageResult.error.message
891
+ );
892
+ }
893
+ const { data } = pageResult;
894
+ if (requestBody.action === "deny") {
895
+ return {
896
+ redirectUrl: buildOAuthRedirectUrl(data.redirectUri, {
897
+ error: OAuthRfcCodes.ACCESS_DENIED,
898
+ error_description: "The resource owner denied the request",
899
+ state: data.state
900
+ })
901
+ };
902
+ }
903
+ const code = (0, import_crypto3.randomBytes)(32).toString("base64url");
904
+ const expiresAt = new Date(Date.now() + AUTH_CODE_TTL_MS).toISOString();
905
+ await this.oauthRepo.create({
906
+ code,
907
+ client_id: data.clientId,
908
+ user_id: session.userId,
909
+ redirect_uri: data.redirectUri,
910
+ scope: data.scopes.join(" ") || null,
911
+ code_challenge: data.codeChallenge ?? null,
912
+ code_challenge_method: data.codeChallengeMethod ?? null,
913
+ expires_at: expiresAt
914
+ });
915
+ void requestBody.trust;
916
+ return {
917
+ redirectUrl: buildOAuthRedirectUrl(data.redirectUri, {
918
+ code,
919
+ state: data.state
920
+ })
921
+ };
922
+ }
923
+ /**
924
+ * @override
925
+ */
926
+ async exchangeToken(rawFields) {
927
+ return await this.tokenService.exchangeToken(rawFields);
928
+ }
929
+ /**
930
+ * @override
931
+ */
932
+ async revokeToken(rawFields) {
933
+ return await this.tokenService.revokeToken(rawFields);
934
+ }
935
+ /**
936
+ * @override
937
+ */
938
+ async logoutUser(userId) {
939
+ await this.oauthRepo.revokeRefreshTokensByUserId(userId);
940
+ await this.oauthRepo.upsertUserCredentials(userId, {
941
+ provider_refresh_token: null,
942
+ provider_session_token: null
943
+ });
944
+ await this.oauthSession.clearSession();
945
+ }
946
+ /**
947
+ * @override
948
+ */
949
+ async getUserInfo(accessToken) {
950
+ try {
951
+ const profile = await this.userAdapter.getUserInfoByAccessToken(accessToken);
952
+ return this.toUserInfoResponse(profile);
953
+ } catch {
954
+ throw new OAuthWrapperError(OAuthRfcCodes.INVALID_TOKEN, 401);
955
+ }
956
+ }
957
+ toUserInfoResponse(profile) {
958
+ const sub = String(profile.id);
959
+ if (!sub || sub === "NaN") {
960
+ throw new OAuthWrapperError(OAuthRfcCodes.INVALID_TOKEN, 401);
961
+ }
962
+ const email = profile.email?.trim();
963
+ if (!email) {
964
+ throw new OAuthWrapperError(OAuthRfcCodes.INVALID_TOKEN, 401);
965
+ }
966
+ const nameFromParts = [profile.first_name, profile.last_name].filter(Boolean).join(" ");
967
+ const name = profile.name?.trim() || nameFromParts || email;
968
+ return {
969
+ sub,
970
+ email,
971
+ name,
972
+ ...profile.roles?.length ? { roles: profile.roles } : {}
973
+ };
974
+ }
975
+ };
976
+
977
+ // src/server/utils/clientSecretHash.ts
978
+ var import_crypto4 = require("crypto");
979
+ var import_util = require("util");
980
+ var scryptAsync = (0, import_util.promisify)(import_crypto4.scrypt);
981
+ var PREFIX = "scrypt";
982
+ var KEY_LEN = 64;
983
+ async function hashClientSecret(secret) {
984
+ const salt = (0, import_crypto4.randomBytes)(16);
985
+ const derived = await scryptAsync(secret, salt, KEY_LEN);
986
+ return `${PREFIX}$${salt.toString("base64")}$${derived.toString("base64")}`;
987
+ }
988
+ async function verifyClientSecret(secret, storedHash) {
989
+ const parts = storedHash.split("$");
990
+ if (parts.length !== 3 || parts[0] !== PREFIX) {
991
+ return false;
992
+ }
993
+ const salt = Buffer.from(parts[1], "base64");
994
+ const expected = Buffer.from(parts[2], "base64");
995
+ const derived = await scryptAsync(secret, salt, expected.length);
996
+ if (derived.length !== expected.length) {
997
+ return false;
998
+ }
999
+ return (0, import_crypto4.timingSafeEqual)(derived, expected);
1000
+ }
1001
+ // Annotate the CommonJS export names for ESM import in node:
1002
+ 0 && (module.exports = {
1003
+ OAuthAuthorizationCodeRowSchema,
1004
+ OAuthAuthorizeQuerySchema,
1005
+ OAuthClientCreateResponseSchema,
1006
+ OAuthClientCreateSchema,
1007
+ OAuthClientDetailSchema,
1008
+ OAuthClientListItemSchema,
1009
+ OAuthClientRowSchema,
1010
+ OAuthClientSecretRotateResponseSchema,
1011
+ OAuthClientUpdateSchema,
1012
+ OAuthClientsService,
1013
+ OAuthConsentBodySchema,
1014
+ OAuthRefreshTokenSchema,
1015
+ OAuthRfcCodes,
1016
+ OAuthTokenAuthorizationCodeSchema,
1017
+ OAuthTokenErrorResponseSchema,
1018
+ OAuthTokenRefreshSchema,
1019
+ OAuthTokenRequestSchema,
1020
+ OAuthTokenResponseSchema,
1021
+ OAuthTokenRevokeSchema,
1022
+ OAuthTokenService,
1023
+ OAuthUserCredentialsSchema,
1024
+ OAuthUserInfoErrorResponseSchema,
1025
+ OAuthUserInfoResponseSchema,
1026
+ OAuthWrapperError,
1027
+ OAuthWrapperService,
1028
+ buildOAuthRedirectUrl,
1029
+ computePkceS256Challenge,
1030
+ computeS256CodeChallenge,
1031
+ generatePkceVerifier,
1032
+ hashClientSecret,
1033
+ isOAuthRedirectUri,
1034
+ isRedirectUriAllowed,
1035
+ isValidCodeChallenge,
1036
+ isValidCodeVerifier,
1037
+ normalizeQuery,
1038
+ parseScopeList,
1039
+ randomOAuthState,
1040
+ validatePkceParams,
1041
+ verifyClientSecret,
1042
+ verifyPkceS256
1043
+ });