sonamu 0.11.13 → 0.11.14

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.
@@ -1 +1 @@
1
- {"version":3,"file":"better-auth-request.d.ts","sourceRoot":"","sources":["../../src/auth/better-auth-request.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,cAAc,EAAE,MAAM,SAAS,CAAC;AAK9C,wBAAgB,uBAAuB,CACrC,OAAO,EAAE,cAAc,EACvB,gBAAgB,CAAC,EAAE,SAAS,MAAM,EAAE,GACnC,OAAO,CAYT"}
1
+ {"version":3,"file":"better-auth-request.d.ts","sourceRoot":"","sources":["../../src/auth/better-auth-request.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,cAAc,EAAE,MAAM,SAAS,CAAC;AAiD9C,wBAAgB,uBAAuB,CACrC,OAAO,EAAE,cAAc,EACvB,gBAAgB,CAAC,EAAE,SAAS,MAAM,EAAE,GACnC,OAAO,CAYT"}
@@ -1,7 +1,30 @@
1
1
  import { __esmMin } from "../_virtual/_rolldown/runtime.js";
2
2
  import { convertFastifyHeadersToStandard, init_utils } from "../utils/utils.js";
3
3
  import { init_client_ip, replaceClientIpHeaders } from "./audit-log/client-ip.js";
4
+ import { z } from "zod";
4
5
  //#region src/auth/better-auth-request.ts
6
+ /** charset 등 파라미터나 대소문자 변형도 포함해 urlencoded content-type인지 판별한다 (RFC 9110 §8.3.1) */
7
+ function isUrlEncodedContentType(headers) {
8
+ return headers.get("content-type")?.toLowerCase().startsWith("application/x-www-form-urlencoded") ?? false;
9
+ }
10
+ /**
11
+ * Fastify가 파싱한 본문을 원래 content-type에 맞게 재직렬화한다.
12
+ * urlencoded 본문을 JSON으로 재직렬화하면 better-auth가 폼 콜백(예: Apple form_post)을
13
+ * 해석하지 못하므로(state_not_found), content-type과 본문 포맷을 일치시킨다.
14
+ */
15
+ function serializeBody(request, headers) {
16
+ if (!isUrlEncodedContentType(headers)) return JSON.stringify(request.body);
17
+ const rawBody = rawUrlEncodedBodySchema.safeParse(request.body);
18
+ if (rawBody.success) return rawBody.data;
19
+ const recordBody = urlEncodedRecordSchema.safeParse(request.body);
20
+ if (!recordBody.success) return JSON.stringify(request.body);
21
+ const params = new URLSearchParams();
22
+ for (const [key, value] of Object.entries(recordBody.data)) {
23
+ const items = Array.isArray(value) ? value : [value];
24
+ for (const item of items) params.append(key, String(item));
25
+ }
26
+ return params.toString();
27
+ }
5
28
  function createBetterAuthRequest(request, ipAddressHeaders) {
6
29
  const url = new URL(request.url, `http://${request.headers.host}`);
7
30
  const headers = convertFastifyHeadersToStandard(request.headers);
@@ -10,15 +33,23 @@ function createBetterAuthRequest(request, ipAddressHeaders) {
10
33
  method: request.method,
11
34
  headers
12
35
  };
13
- if (request.body) init.body = JSON.stringify(request.body);
36
+ if (request.body) init.body = serializeBody(request, headers);
14
37
  return new Request(url.toString(), init);
15
38
  }
39
+ var rawUrlEncodedBodySchema, urlEncodedValueSchema, urlEncodedRecordSchema;
16
40
  var init_better_auth_request = __esmMin((() => {
17
41
  init_utils();
18
42
  init_client_ip();
43
+ rawUrlEncodedBodySchema = z.string();
44
+ urlEncodedValueSchema = z.union([
45
+ z.string(),
46
+ z.number(),
47
+ z.boolean()
48
+ ]);
49
+ urlEncodedRecordSchema = z.record(z.string(), z.union([urlEncodedValueSchema, z.array(urlEncodedValueSchema)]));
19
50
  }));
20
51
  //#endregion
21
52
  init_better_auth_request();
22
53
  export { createBetterAuthRequest, init_better_auth_request };
23
54
 
24
- //# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoiYmV0dGVyLWF1dGgtcmVxdWVzdC5qcyIsIm5hbWVzIjpbXSwic291cmNlcyI6WyIuLi8uLi9zcmMvYXV0aC9iZXR0ZXItYXV0aC1yZXF1ZXN0LnRzIl0sInNvdXJjZXNDb250ZW50IjpbImltcG9ydCB7IHR5cGUgRmFzdGlmeVJlcXVlc3QgfSBmcm9tIFwiZmFzdGlmeVwiO1xuXG5pbXBvcnQgeyBjb252ZXJ0RmFzdGlmeUhlYWRlcnNUb1N0YW5kYXJkIH0gZnJvbSBcIi4uL3V0aWxzL3V0aWxzXCI7XG5pbXBvcnQgeyByZXBsYWNlQ2xpZW50SXBIZWFkZXJzIH0gZnJvbSBcIi4vYXVkaXQtbG9nL2NsaWVudC1pcFwiO1xuXG5leHBvcnQgZnVuY3Rpb24gY3JlYXRlQmV0dGVyQXV0aFJlcXVlc3QoXG4gIHJlcXVlc3Q6IEZhc3RpZnlSZXF1ZXN0LFxuICBpcEFkZHJlc3NIZWFkZXJzPzogcmVhZG9ubHkgc3RyaW5nW10sXG4pOiBSZXF1ZXN0IHtcbiAgY29uc3QgdXJsID0gbmV3IFVSTChyZXF1ZXN0LnVybCwgYGh0dHA6Ly8ke3JlcXVlc3QuaGVhZGVycy5ob3N0fWApO1xuICBjb25zdCBoZWFkZXJzID0gY29udmVydEZhc3RpZnlIZWFkZXJzVG9TdGFuZGFyZChyZXF1ZXN0LmhlYWRlcnMpO1xuXG4gIHJlcGxhY2VDbGllbnRJcEhlYWRlcnMoaGVhZGVycywgcmVxdWVzdC5pcCwgaXBBZGRyZXNzSGVhZGVycyk7XG5cbiAgY29uc3QgaW5pdDogUmVxdWVzdEluaXQgPSB7XG4gICAgbWV0aG9kOiByZXF1ZXN0Lm1ldGhvZCxcbiAgICBoZWFkZXJzLFxuICB9O1xuICBpZiAocmVxdWVzdC5ib2R5KSBpbml0LmJvZHkgPSBKU09OLnN0cmluZ2lmeShyZXF1ZXN0LmJvZHkpO1xuICByZXR1cm4gbmV3IFJlcXVlc3QodXJsLnRvU3RyaW5nKCksIGluaXQpO1xufVxuIl0sIm1hcHBpbmdzIjoiOzs7O0FBS0EsU0FBZ0Isd0JBQ2QsU0FDQSxrQkFDUztDQUNULE1BQU0sTUFBTSxJQUFJLElBQUksUUFBUSxLQUFLLFVBQVUsUUFBUSxRQUFRLE1BQU07Q0FDakUsTUFBTSxVQUFVLGdDQUFnQyxRQUFRLE9BQU87Q0FFL0QsdUJBQXVCLFNBQVMsUUFBUSxJQUFJLGdCQUFnQjtDQUU1RCxNQUFNLE9BQW9CO0VBQ3hCLFFBQVEsUUFBUTtFQUNoQjtDQUNGO0NBQ0EsSUFBSSxRQUFRLE1BQU0sS0FBSyxPQUFPLEtBQUssVUFBVSxRQUFRLElBQUk7Q0FDekQsT0FBTyxJQUFJLFFBQVEsSUFBSSxTQUFTLEdBQUcsSUFBSTtBQUN6Qzs7WUFsQmdFO2dCQUNGIn0=
55
+ //# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoiYmV0dGVyLWF1dGgtcmVxdWVzdC5qcyIsIm5hbWVzIjpbXSwic291cmNlcyI6WyIuLi8uLi9zcmMvYXV0aC9iZXR0ZXItYXV0aC1yZXF1ZXN0LnRzIl0sInNvdXJjZXNDb250ZW50IjpbImltcG9ydCB7IHR5cGUgRmFzdGlmeVJlcXVlc3QgfSBmcm9tIFwiZmFzdGlmeVwiO1xuaW1wb3J0IHsgeiB9IGZyb20gXCJ6b2RcIjtcblxuaW1wb3J0IHsgY29udmVydEZhc3RpZnlIZWFkZXJzVG9TdGFuZGFyZCB9IGZyb20gXCIuLi91dGlscy91dGlsc1wiO1xuaW1wb3J0IHsgcmVwbGFjZUNsaWVudElwSGVhZGVycyB9IGZyb20gXCIuL2F1ZGl0LWxvZy9jbGllbnQtaXBcIjtcblxuLyoqIGNoYXJzZXQg65OxIO2MjOudvOuvuO2EsOuCmCDrjIDshozrrLjsnpAg67OA7ZiV64+EIO2PrO2VqO2VtCB1cmxlbmNvZGVkIGNvbnRlbnQtdHlwZeyduOyngCDtjJDrs4TtlZzri6QgKFJGQyA5MTEwIMKnOC4zLjEpICovXG5mdW5jdGlvbiBpc1VybEVuY29kZWRDb250ZW50VHlwZShoZWFkZXJzOiBIZWFkZXJzKTogYm9vbGVhbiB7XG4gIHJldHVybiAoXG4gICAgaGVhZGVycy5nZXQoXCJjb250ZW50LXR5cGVcIik/LnRvTG93ZXJDYXNlKCkuc3RhcnRzV2l0aChcImFwcGxpY2F0aW9uL3gtd3d3LWZvcm0tdXJsZW5jb2RlZFwiKSA/P1xuICAgIGZhbHNlXG4gICk7XG59XG5cbi8qKiDtjIzshJzqsIAg67O466y47J2EIOqwgOqzte2VmOyngCDslYrqs6Ag64SY6ri0IOqyveyasOydmCDsm5DrrLggdXJsZW5jb2RlZCDrrLjsnpDsl7QgKi9cbmNvbnN0IHJhd1VybEVuY29kZWRCb2R5U2NoZW1hID0gei5zdHJpbmcoKTtcblxuLyoqIEZhc3RpZnkgdXJsZW5jb2RlZCDtjIzshJzqsIAg7IOd7ISx7ZWY64qUIO2CpC3qsJIg67O466y4ICjrsJjrs7Ug7YKk64qUIOuwsOyXtOuhnCDtjIzsi7HrkKgpICovXG5jb25zdCB1cmxFbmNvZGVkVmFsdWVTY2hlbWEgPSB6LnVuaW9uKFt6LnN0cmluZygpLCB6Lm51bWJlcigpLCB6LmJvb2xlYW4oKV0pO1xuY29uc3QgdXJsRW5jb2RlZFJlY29yZFNjaGVtYSA9IHoucmVjb3JkKFxuICB6LnN0cmluZygpLFxuICB6LnVuaW9uKFt1cmxFbmNvZGVkVmFsdWVTY2hlbWEsIHouYXJyYXkodXJsRW5jb2RlZFZhbHVlU2NoZW1hKV0pLFxuKTtcblxuLyoqXG4gKiBGYXN0aWZ56rCAIO2MjOyLse2VnCDrs7jrrLjsnYQg7JuQ656YIGNvbnRlbnQtdHlwZeyXkCDrp57qsowg7J6s7KeB66Cs7ZmU7ZWc64ukLlxuICogdXJsZW5jb2RlZCDrs7jrrLjsnYQgSlNPTuycvOuhnCDsnqzsp4HroKztmZTtlZjrqbQgYmV0dGVyLWF1dGjqsIAg7Y+8IOy9nOuwsSjsmIg6IEFwcGxlIGZvcm1fcG9zdCnsnYRcbiAqIO2VtOyEne2VmOyngCDrqrvtlZjrr4DroZwoc3RhdGVfbm90X2ZvdW5kKSwgY29udGVudC10eXBl6rO8IOuzuOusuCDtj6zrp7fsnYQg7J287LmY7Iuc7YKo64ukLlxuICovXG5mdW5jdGlvbiBzZXJpYWxpemVCb2R5KHJlcXVlc3Q6IEZhc3RpZnlSZXF1ZXN0LCBoZWFkZXJzOiBIZWFkZXJzKTogc3RyaW5nIHtcbiAgaWYgKCFpc1VybEVuY29kZWRDb250ZW50VHlwZShoZWFkZXJzKSkgcmV0dXJuIEpTT04uc3RyaW5naWZ5KHJlcXVlc3QuYm9keSk7XG5cbiAgLy8g7J2066+4IHVybGVuY29kZWQg66y47J6Q7Je07J2066m0IOq3uOuMgOuhnCDsoITri6ztlbQg6rCSIOyZnOqzoeydhCDtlLztlZzri6RcbiAgY29uc3QgcmF3Qm9keSA9IHJhd1VybEVuY29kZWRCb2R5U2NoZW1hLnNhZmVQYXJzZShyZXF1ZXN0LmJvZHkpO1xuICBpZiAocmF3Qm9keS5zdWNjZXNzKSByZXR1cm4gcmF3Qm9keS5kYXRhO1xuXG4gIGNvbnN0IHJlY29yZEJvZHkgPSB1cmxFbmNvZGVkUmVjb3JkU2NoZW1hLnNhZmVQYXJzZShyZXF1ZXN0LmJvZHkpO1xuICAvLyDsmIjsg4Eg67CWIO2Yle2DnOydmCDrs7jrrLjsnYAg6riw7KG0IOuPmeyekShKU09OIOyngeugrO2ZlCnsnYQg7Jyg7KeA7ZWc64ukXG4gIGlmICghcmVjb3JkQm9keS5zdWNjZXNzKSByZXR1cm4gSlNPTi5zdHJpbmdpZnkocmVxdWVzdC5ib2R5KTtcblxuICBjb25zdCBwYXJhbXMgPSBuZXcgVVJMU2VhcmNoUGFyYW1zKCk7XG4gIGZvciAoY29uc3QgW2tleSwgdmFsdWVdIG9mIE9iamVjdC5lbnRyaWVzKHJlY29yZEJvZHkuZGF0YSkpIHtcbiAgICAvLyDrsLDsl7TsnYAgXCJhLGJcIuuhnCDtlansuZjsp4Ag7JWK6rOgIOuwmOuztSDtgqQodGFncz1hJnRhZ3M9YinroZwg7KeB66Cs7ZmU7ZWc64ukXG4gICAgY29uc3QgaXRlbXMgPSBBcnJheS5pc0FycmF5KHZhbHVlKSA/IHZhbHVlIDogW3ZhbHVlXTtcbiAgICBmb3IgKGNvbnN0IGl0ZW0gb2YgaXRlbXMpIHBhcmFtcy5hcHBlbmQoa2V5LCBTdHJpbmcoaXRlbSkpO1xuICB9XG4gIHJldHVybiBwYXJhbXMudG9TdHJpbmcoKTtcbn1cblxuZXhwb3J0IGZ1bmN0aW9uIGNyZWF0ZUJldHRlckF1dGhSZXF1ZXN0KFxuICByZXF1ZXN0OiBGYXN0aWZ5UmVxdWVzdCxcbiAgaXBBZGRyZXNzSGVhZGVycz86IHJlYWRvbmx5IHN0cmluZ1tdLFxuKTogUmVxdWVzdCB7XG4gIGNvbnN0IHVybCA9IG5ldyBVUkwocmVxdWVzdC51cmwsIGBodHRwOi8vJHtyZXF1ZXN0LmhlYWRlcnMuaG9zdH1gKTtcbiAgY29uc3QgaGVhZGVycyA9IGNvbnZlcnRGYXN0aWZ5SGVhZGVyc1RvU3RhbmRhcmQocmVxdWVzdC5oZWFkZXJzKTtcblxuICByZXBsYWNlQ2xpZW50SXBIZWFkZXJzKGhlYWRlcnMsIHJlcXVlc3QuaXAsIGlwQWRkcmVzc0hlYWRlcnMpO1xuXG4gIGNvbnN0IGluaXQ6IFJlcXVlc3RJbml0ID0ge1xuICAgIG1ldGhvZDogcmVxdWVzdC5tZXRob2QsXG4gICAgaGVhZGVycyxcbiAgfTtcbiAgaWYgKHJlcXVlc3QuYm9keSkgaW5pdC5ib2R5ID0gc2VyaWFsaXplQm9keShyZXF1ZXN0LCBoZWFkZXJzKTtcbiAgcmV0dXJuIG5ldyBSZXF1ZXN0KHVybC50b1N0cmluZygpLCBpbml0KTtcbn1cbiJdLCJtYXBwaW5ncyI6Ijs7Ozs7O0FBT0EsU0FBUyx3QkFBd0IsU0FBMkI7Q0FDMUQsT0FDRSxRQUFRLElBQUksY0FBYyxDQUFDLEVBQUUsWUFBWSxDQUFDLENBQUMsV0FBVyxtQ0FBbUMsS0FDekY7QUFFSjs7Ozs7O0FBaUJBLFNBQVMsY0FBYyxTQUF5QixTQUEwQjtDQUN4RSxJQUFJLENBQUMsd0JBQXdCLE9BQU8sR0FBRyxPQUFPLEtBQUssVUFBVSxRQUFRLElBQUk7Q0FHekUsTUFBTSxVQUFVLHdCQUF3QixVQUFVLFFBQVEsSUFBSTtDQUM5RCxJQUFJLFFBQVEsU0FBUyxPQUFPLFFBQVE7Q0FFcEMsTUFBTSxhQUFhLHVCQUF1QixVQUFVLFFBQVEsSUFBSTtDQUVoRSxJQUFJLENBQUMsV0FBVyxTQUFTLE9BQU8sS0FBSyxVQUFVLFFBQVEsSUFBSTtDQUUzRCxNQUFNLFNBQVMsSUFBSSxnQkFBZ0I7Q0FDbkMsS0FBSyxNQUFNLENBQUMsS0FBSyxVQUFVLE9BQU8sUUFBUSxXQUFXLElBQUksR0FBRztFQUUxRCxNQUFNLFFBQVEsTUFBTSxRQUFRLEtBQUssSUFBSSxRQUFRLENBQUMsS0FBSztFQUNuRCxLQUFLLE1BQU0sUUFBUSxPQUFPLE9BQU8sT0FBTyxLQUFLLE9BQU8sSUFBSSxDQUFDO0NBQzNEO0NBQ0EsT0FBTyxPQUFPLFNBQVM7QUFDekI7QUFFQSxTQUFnQix3QkFDZCxTQUNBLGtCQUNTO0NBQ1QsTUFBTSxNQUFNLElBQUksSUFBSSxRQUFRLEtBQUssVUFBVSxRQUFRLFFBQVEsTUFBTTtDQUNqRSxNQUFNLFVBQVUsZ0NBQWdDLFFBQVEsT0FBTztDQUUvRCx1QkFBdUIsU0FBUyxRQUFRLElBQUksZ0JBQWdCO0NBRTVELE1BQU0sT0FBb0I7RUFDeEIsUUFBUSxRQUFRO0VBQ2hCO0NBQ0Y7Q0FDQSxJQUFJLFFBQVEsTUFBTSxLQUFLLE9BQU8sY0FBYyxTQUFTLE9BQU87Q0FDNUQsT0FBTyxJQUFJLFFBQVEsSUFBSSxTQUFTLEdBQUcsSUFBSTtBQUN6Qzs7O1lBN0RnRTtnQkFDRjtDQVd4RCwwQkFBMEIsRUFBRSxPQUFPO0NBR25DLHdCQUF3QixFQUFFLE1BQU07RUFBQyxFQUFFLE9BQU87RUFBRyxFQUFFLE9BQU87RUFBRyxFQUFFLFFBQVE7Q0FBQyxDQUFDO0NBQ3JFLHlCQUF5QixFQUFFLE9BQy9CLEVBQUUsT0FBTyxHQUNULEVBQUUsTUFBTSxDQUFDLHVCQUF1QixFQUFFLE1BQU0scUJBQXFCLENBQUMsQ0FBQyxDQUNqRSJ9
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sonamu",
3
- "version": "0.11.13",
3
+ "version": "0.11.14",
4
4
  "description": "Sonamu — TypeScript Fullstack API Framework",
5
5
  "keywords": [
6
6
  "framework",
@@ -0,0 +1,210 @@
1
+ import { type FastifyRequest } from "fastify";
2
+ import { describe, expect, test } from "vitest";
3
+
4
+ import { createBetterAuthRequest } from "../better-auth-request";
5
+
6
+ // createBetterAuthRequest가 실제로 사용하는 FastifyRequest 필드만 추린 최소 모킹 타입
7
+ type MockableRequestFields = Pick<FastifyRequest, "url" | "method" | "headers" | "body" | "ip">;
8
+
9
+ function createMockFastifyRequest(overrides: {
10
+ url?: string;
11
+ method?: string;
12
+ headers?: Record<string, string | string[]>;
13
+ body?: unknown;
14
+ ip?: string;
15
+ }): FastifyRequest {
16
+ const mock: MockableRequestFields = {
17
+ url: overrides.url ?? "/api/auth/callback/apple",
18
+ method: overrides.method ?? "POST",
19
+ headers: { host: "localhost:3000", ...overrides.headers },
20
+ body: overrides.body ?? null,
21
+ ip: overrides.ip ?? "127.0.0.1",
22
+ };
23
+ // SAFETY: 대상 함수는 위 필드만 접근하므로 최소 모킹을 FastifyRequest로 간주해도 안전하다.
24
+ return mock as FastifyRequest;
25
+ }
26
+
27
+ describe("createBetterAuthRequest — urlencoded 본문 직렬화 (SON-549)", () => {
28
+ test("urlencoded POST 콜백 본문을 JSON이 아닌 urlencoded로 직렬화한다", async () => {
29
+ // Apple Sign-In form_post 콜백 형태의 본문
30
+ const body = {
31
+ state: "abc123",
32
+ code: "authcode-456",
33
+ user: '{"name":{"firstName":"길동"}}',
34
+ };
35
+ const request = createMockFastifyRequest({
36
+ headers: { "content-type": "application/x-www-form-urlencoded" },
37
+ body,
38
+ });
39
+
40
+ const result = createBetterAuthRequest(request);
41
+ const text = await result.text();
42
+
43
+ // JSON 직렬화 결과가 아니어야 한다
44
+ expect(text).not.toBe(JSON.stringify(body));
45
+
46
+ // urlencoded로 재파싱 시 원본 값이 모두 복원되어야 한다
47
+ const params = new URLSearchParams(text);
48
+ expect(params.get("state")).toBe("abc123");
49
+ expect(params.get("code")).toBe("authcode-456");
50
+ expect(params.get("user")).toBe('{"name":{"firstName":"길동"}}');
51
+ });
52
+
53
+ test("charset 파라미터가 붙은 content-type도 urlencoded로 처리한다", async () => {
54
+ const body = { state: "abc123", code: "authcode-456" };
55
+ const request = createMockFastifyRequest({
56
+ headers: {
57
+ "content-type": "application/x-www-form-urlencoded; charset=UTF-8",
58
+ },
59
+ body,
60
+ });
61
+
62
+ const result = createBetterAuthRequest(request);
63
+ const params = new URLSearchParams(await result.text());
64
+
65
+ expect(params.get("state")).toBe("abc123");
66
+ expect(params.get("code")).toBe("authcode-456");
67
+ });
68
+
69
+ test("특수문자(=, &, +, 공백, 한글)가 포함된 값이 왕복 보존된다", async () => {
70
+ const trickyState = "a=b&c+d e한글";
71
+ const body = { state: trickyState, code: "x" };
72
+ const request = createMockFastifyRequest({
73
+ headers: { "content-type": "application/x-www-form-urlencoded" },
74
+ body,
75
+ });
76
+
77
+ const result = createBetterAuthRequest(request);
78
+ const params = new URLSearchParams(await result.text());
79
+
80
+ expect(params.get("state")).toBe(trickyState);
81
+ expect(params.get("code")).toBe("x");
82
+ });
83
+
84
+ test("본문이 이미 urlencoded 문자열이면 값이 그대로 보존된다", async () => {
85
+ const request = createMockFastifyRequest({
86
+ headers: { "content-type": "application/x-www-form-urlencoded" },
87
+ body: "state=abc&code=def",
88
+ });
89
+
90
+ const result = createBetterAuthRequest(request);
91
+ const params = new URLSearchParams(await result.text());
92
+
93
+ expect(params.get("state")).toBe("abc");
94
+ expect(params.get("code")).toBe("def");
95
+ });
96
+
97
+ test("배열 값은 반복 키로 직렬화되어 값 손실이 없다", async () => {
98
+ const request = createMockFastifyRequest({
99
+ headers: { "content-type": "application/x-www-form-urlencoded" },
100
+ body: { tags: ["a", "b"] },
101
+ });
102
+
103
+ const result = createBetterAuthRequest(request);
104
+ const params = new URLSearchParams(await result.text());
105
+
106
+ // "a,b"로 합쳐지지 않고 반복 키(tags=a&tags=b)로 유지되어야 한다
107
+ expect(params.getAll("tags")).toEqual(["a", "b"]);
108
+ });
109
+
110
+ test("대소문자가 섞인 content-type 값도 urlencoded로 감지한다", async () => {
111
+ // HTTP 미디어 타입은 대소문자를 구분하지 않으며(RFC 9110 §8.3.1),
112
+ // Fastify 파서 조회도 소문자화된 타입으로 매칭하므로 브리지도 동일하게 감지해야 한다
113
+ const body = { state: "mixed-case", code: "authcode-789" };
114
+ const request = createMockFastifyRequest({
115
+ headers: {
116
+ "content-type": "Application/X-WWW-Form-Urlencoded; charset=UTF-8",
117
+ },
118
+ body,
119
+ });
120
+
121
+ const result = createBetterAuthRequest(request);
122
+ const text = await result.text();
123
+
124
+ expect(text).not.toBe(JSON.stringify(body));
125
+
126
+ const params = new URLSearchParams(text);
127
+ expect(params.get("state")).toBe("mixed-case");
128
+ expect(params.get("code")).toBe("authcode-789");
129
+ });
130
+
131
+ test("urlencoded content-type에 중첩 객체 본문이면 JSON 직렬화 폴백을 유지한다", async () => {
132
+ // qs 계열 파서가 생성하는 중첩 객체는 평탄한 레코드가 아니므로
133
+ // 본문을 버리거나 예외를 던지지 않고 기존 JSON 직렬화로 보존해야 한다
134
+ const body = { user: { name: "길동" } };
135
+ const request = createMockFastifyRequest({
136
+ headers: { "content-type": "application/x-www-form-urlencoded" },
137
+ body,
138
+ });
139
+
140
+ const result = createBetterAuthRequest(request);
141
+
142
+ expect(await result.text()).toBe(JSON.stringify(body));
143
+ });
144
+ });
145
+
146
+ describe("createBetterAuthRequest — 기존 동작 회귀 보장", () => {
147
+ test("JSON POST 본문은 JSON.stringify 결과 그대로 전달한다", async () => {
148
+ const body = { email: "user@example.com", password: "secret" };
149
+ const request = createMockFastifyRequest({
150
+ headers: { "content-type": "application/json" },
151
+ body,
152
+ });
153
+
154
+ const result = createBetterAuthRequest(request);
155
+
156
+ expect(await result.text()).toBe(JSON.stringify(body));
157
+ });
158
+
159
+ test("content-type 없는 POST 본문은 JSON.stringify를 유지한다", async () => {
160
+ const body = { foo: "bar" };
161
+ const request = createMockFastifyRequest({ body });
162
+
163
+ const result = createBetterAuthRequest(request);
164
+
165
+ expect(await result.text()).toBe(JSON.stringify(body));
166
+ });
167
+
168
+ test("GET 요청은 본문 없이 메서드와 URL을 보존한다", () => {
169
+ const request = createMockFastifyRequest({
170
+ url: "/api/auth/session?foo=bar",
171
+ method: "GET",
172
+ body: null,
173
+ });
174
+
175
+ const result = createBetterAuthRequest(request);
176
+
177
+ expect(result.body).toBeNull();
178
+ expect(result.method).toBe("GET");
179
+ expect(result.url).toBe("http://localhost:3000/api/auth/session?foo=bar");
180
+ });
181
+
182
+ test("urlencoded 경로에서도 클라이언트 IP 치환과 기타 헤더가 유지된다", async () => {
183
+ const request = createMockFastifyRequest({
184
+ headers: {
185
+ "content-type": "application/x-www-form-urlencoded",
186
+ "x-forwarded-for": "9.9.9.9",
187
+ "user-agent": "test-agent",
188
+ cookie: "session=xyz",
189
+ },
190
+ body: { state: "abc" },
191
+ ip: "1.2.3.4",
192
+ });
193
+
194
+ const result = createBetterAuthRequest(request, ["x-custom-ip"]);
195
+
196
+ // replaceClientIpHeaders가 신뢰 가능한 request.ip로 치환해야 한다
197
+ expect(result.headers.get("x-forwarded-for")).toBe("1.2.3.4");
198
+ expect(result.headers.get("x-real-ip")).toBe("1.2.3.4");
199
+ expect(result.headers.get("x-custom-ip")).toBe("1.2.3.4");
200
+
201
+ // 그 외 헤더는 변경 없이 전달되어야 한다
202
+ expect(result.headers.get("user-agent")).toBe("test-agent");
203
+ expect(result.headers.get("cookie")).toBe("session=xyz");
204
+ expect(result.headers.get("content-type")).toBe("application/x-www-form-urlencoded");
205
+
206
+ // urlencoded 본문 직렬화도 함께 보장한다
207
+ const params = new URLSearchParams(await result.text());
208
+ expect(params.get("state")).toBe("abc");
209
+ });
210
+ });
@@ -1,8 +1,52 @@
1
1
  import { type FastifyRequest } from "fastify";
2
+ import { z } from "zod";
2
3
 
3
4
  import { convertFastifyHeadersToStandard } from "../utils/utils";
4
5
  import { replaceClientIpHeaders } from "./audit-log/client-ip";
5
6
 
7
+ /** charset 등 파라미터나 대소문자 변형도 포함해 urlencoded content-type인지 판별한다 (RFC 9110 §8.3.1) */
8
+ function isUrlEncodedContentType(headers: Headers): boolean {
9
+ return (
10
+ headers.get("content-type")?.toLowerCase().startsWith("application/x-www-form-urlencoded") ??
11
+ false
12
+ );
13
+ }
14
+
15
+ /** 파서가 본문을 가공하지 않고 넘긴 경우의 원문 urlencoded 문자열 */
16
+ const rawUrlEncodedBodySchema = z.string();
17
+
18
+ /** Fastify urlencoded 파서가 생성하는 키-값 본문 (반복 키는 배열로 파싱됨) */
19
+ const urlEncodedValueSchema = z.union([z.string(), z.number(), z.boolean()]);
20
+ const urlEncodedRecordSchema = z.record(
21
+ z.string(),
22
+ z.union([urlEncodedValueSchema, z.array(urlEncodedValueSchema)]),
23
+ );
24
+
25
+ /**
26
+ * Fastify가 파싱한 본문을 원래 content-type에 맞게 재직렬화한다.
27
+ * urlencoded 본문을 JSON으로 재직렬화하면 better-auth가 폼 콜백(예: Apple form_post)을
28
+ * 해석하지 못하므로(state_not_found), content-type과 본문 포맷을 일치시킨다.
29
+ */
30
+ function serializeBody(request: FastifyRequest, headers: Headers): string {
31
+ if (!isUrlEncodedContentType(headers)) return JSON.stringify(request.body);
32
+
33
+ // 이미 urlencoded 문자열이면 그대로 전달해 값 왜곡을 피한다
34
+ const rawBody = rawUrlEncodedBodySchema.safeParse(request.body);
35
+ if (rawBody.success) return rawBody.data;
36
+
37
+ const recordBody = urlEncodedRecordSchema.safeParse(request.body);
38
+ // 예상 밖 형태의 본문은 기존 동작(JSON 직렬화)을 유지한다
39
+ if (!recordBody.success) return JSON.stringify(request.body);
40
+
41
+ const params = new URLSearchParams();
42
+ for (const [key, value] of Object.entries(recordBody.data)) {
43
+ // 배열은 "a,b"로 합치지 않고 반복 키(tags=a&tags=b)로 직렬화한다
44
+ const items = Array.isArray(value) ? value : [value];
45
+ for (const item of items) params.append(key, String(item));
46
+ }
47
+ return params.toString();
48
+ }
49
+
6
50
  export function createBetterAuthRequest(
7
51
  request: FastifyRequest,
8
52
  ipAddressHeaders?: readonly string[],
@@ -16,6 +60,6 @@ export function createBetterAuthRequest(
16
60
  method: request.method,
17
61
  headers,
18
62
  };
19
- if (request.body) init.body = JSON.stringify(request.body);
63
+ if (request.body) init.body = serializeBody(request, headers);
20
64
  return new Request(url.toString(), init);
21
65
  }