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