@spfn/auth 0.2.0-beta.81 → 0.2.0-beta.83

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/server.js CHANGED
@@ -6937,6 +6937,14 @@ var init_schema5 = __esm({
6937
6937
  examples: ["your-kakao-client-secret"]
6938
6938
  })
6939
6939
  },
6940
+ SPFN_AUTH_KAKAO_ADMIN_KEY: {
6941
+ ...envString({
6942
+ description: "Kakao app admin key. Required to verify the User Unlinked webhook (Authorization: KakaoAK header).",
6943
+ required: false,
6944
+ sensitive: true,
6945
+ examples: ["your-kakao-admin-key"]
6946
+ })
6947
+ },
6940
6948
  SPFN_AUTH_KAKAO_SCOPES: {
6941
6949
  ...envString({
6942
6950
  description: "Comma-separated Kakao consent scopes. Defaults to account_email.",
@@ -8398,6 +8406,16 @@ var authDeletionCompletedEvent = defineEvent(
8398
8406
  purgeStrategy: Type.Union([Type.Literal("anonymize"), Type.Literal("hard-delete")])
8399
8407
  })
8400
8408
  );
8409
+ var oauthUnlinkedEvent = defineEvent(
8410
+ "auth.oauth.unlinked",
8411
+ Type.Object({
8412
+ userId: Type.String(),
8413
+ provider: AuthProviderSchema,
8414
+ providerUserId: Type.String(),
8415
+ /** provider가 전달한 해제 경로 (kakao referrer_type 등) */
8416
+ reason: Type.Optional(Type.String())
8417
+ })
8418
+ );
8401
8419
 
8402
8420
  // src/server/services/account-deletion.service.ts
8403
8421
  var POSTGRES_UNIQUE_VIOLATION = "23505";
@@ -9521,6 +9539,14 @@ async function verifyOAuthState(encryptedState) {
9521
9539
  }
9522
9540
 
9523
9541
  // src/server/lib/oauth/provider.ts
9542
+ var UnlinkNotifyRejection = class extends Error {
9543
+ status;
9544
+ constructor(status, message) {
9545
+ super(message);
9546
+ this.name = "UnlinkNotifyRejection";
9547
+ this.status = status;
9548
+ }
9549
+ };
9524
9550
  var registry2 = /* @__PURE__ */ new Map();
9525
9551
  function registerOAuthProvider(provider) {
9526
9552
  registry2.set(provider.id, provider);
@@ -9835,6 +9861,7 @@ registerOAuthProvider(githubProvider);
9835
9861
  // src/server/lib/oauth/kakao-provider.ts
9836
9862
  init_config();
9837
9863
  import { ValidationError as ValidationError7 } from "@spfn/core/errors";
9864
+ import { timingSafeEqual } from "crypto";
9838
9865
  var KAKAO_AUTH_URL = "https://kauth.kakao.com/oauth/authorize";
9839
9866
  var KAKAO_TOKEN_URL = "https://kauth.kakao.com/oauth/token";
9840
9867
  var KAKAO_USERINFO_URL = "https://kapi.kakao.com/v2/user/me";
@@ -9938,6 +9965,32 @@ var kakaoProvider = {
9938
9965
  params.set("client_secret", config2.clientSecret);
9939
9966
  }
9940
9967
  return requestKakaoTokens(params);
9968
+ },
9969
+ /**
9970
+ * 카카오 연결 해제 웹훅(User Unlinked) 검증
9971
+ *
9972
+ * 카카오는 `Authorization: KakaoAK ${대표 어드민 키}` 헤더로 요청하므로
9973
+ * 어드민 키 일치가 곧 발신자 검증이다. 웹훅 규격상 GET/POST 모두 가능하며
9974
+ * 본문 필드는 app_id · user_id · referrer_type.
9975
+ */
9976
+ async verifyUnlinkNotification(request) {
9977
+ const adminKey = env3.SPFN_AUTH_KAKAO_ADMIN_KEY;
9978
+ if (!adminKey) {
9979
+ throw new UnlinkNotifyRejection(401, "SPFN_AUTH_KAKAO_ADMIN_KEY is not configured");
9980
+ }
9981
+ const expected = Buffer.from(`KakaoAK ${adminKey}`);
9982
+ const actual = Buffer.from(request.authorization ?? "");
9983
+ if (expected.length !== actual.length || !timingSafeEqual(expected, actual)) {
9984
+ throw new UnlinkNotifyRejection(401, "Kakao admin key mismatch");
9985
+ }
9986
+ const userId = request.fields.user_id;
9987
+ if (!userId) {
9988
+ throw new UnlinkNotifyRejection(400, "Kakao unlink webhook is missing user_id");
9989
+ }
9990
+ return {
9991
+ providerUserId: userId,
9992
+ reason: request.fields.referrer_type
9993
+ };
9941
9994
  }
9942
9995
  };
9943
9996
  registerOAuthProvider(kakaoProvider);
@@ -9945,6 +9998,7 @@ registerOAuthProvider(kakaoProvider);
9945
9998
  // src/server/lib/oauth/naver-provider.ts
9946
9999
  init_config();
9947
10000
  import { ValidationError as ValidationError8 } from "@spfn/core/errors";
10001
+ import { createDecipheriv, createHash as createHash3, createHmac, timingSafeEqual as timingSafeEqual2 } from "crypto";
9948
10002
  var NAVER_AUTH_URL = "https://nid.naver.com/oauth2.0/authorize";
9949
10003
  var NAVER_TOKEN_URL = "https://nid.naver.com/oauth2.0/token";
9950
10004
  var NAVER_USERINFO_URL = "https://openapi.naver.com/v1/nid/me";
@@ -9983,6 +10037,25 @@ async function requestNaverTokens(params) {
9983
10037
  expiresIn
9984
10038
  };
9985
10039
  }
10040
+ function deriveNaverUnlinkKey(clientSecret) {
10041
+ return createHash3("md5").update(clientSecret).digest().subarray(0, 16);
10042
+ }
10043
+ function decodeBase64Url(value) {
10044
+ return Buffer.from(value.replace(/-/g, "+").replace(/_/g, "/"), "base64");
10045
+ }
10046
+ function decryptNaverUniqueId(encryptUniqueId, key) {
10047
+ try {
10048
+ const payload = decodeBase64Url(encryptUniqueId);
10049
+ const decipher = createDecipheriv("aes-128-cbc", key, payload.subarray(0, 16));
10050
+ const uniqueId = Buffer.concat([
10051
+ decipher.update(payload.subarray(16)),
10052
+ decipher.final()
10053
+ ]).toString("utf8");
10054
+ return uniqueId || null;
10055
+ } catch {
10056
+ return null;
10057
+ }
10058
+ }
9986
10059
  var naverProvider = {
9987
10060
  id: "naver",
9988
10061
  isEnabled() {
@@ -10038,6 +10111,46 @@ var naverProvider = {
10038
10111
  client_secret: config2.clientSecret,
10039
10112
  refresh_token: refreshToken
10040
10113
  }));
10114
+ },
10115
+ // 네이버 연결 끊기 알림 규격은 성공 응답으로 204 No Content를 요구한다.
10116
+ unlinkNotifyAckStatus: 204,
10117
+ /**
10118
+ * 네이버 연결 끊기 알림 검증
10119
+ *
10120
+ * 파라미터: clientId · encryptUniqueId · timestamp · signature.
10121
+ * signature = base64url( HMAC-SHA256( "clientId=..&encryptUniqueId=..&timestamp=..", key ) ),
10122
+ * encryptUniqueId = base64url( iv(16) + AES-128-CBC-PKCS5(uniqueId, key) ),
10123
+ * key = md5(CLIENT_SECRET)[0..16].
10124
+ *
10125
+ * 복호화된 uniqueId는 프로필 API(/v1/nid/me)의 id와 동일한 이용자 고유 식별자다.
10126
+ * 네이버는 실패 요청을 재시도하지 않으므로 재전송(replay) 방어는 검증 통과 후
10127
+ * 삭제가 멱등이라는 점에 의존한다.
10128
+ */
10129
+ async verifyUnlinkNotification(request) {
10130
+ const clientId = env3.SPFN_AUTH_NAVER_CLIENT_ID;
10131
+ const clientSecret = env3.SPFN_AUTH_NAVER_CLIENT_SECRET;
10132
+ if (!clientId || !clientSecret) {
10133
+ throw new UnlinkNotifyRejection(403, "Naver OAuth is not configured");
10134
+ }
10135
+ const { clientId: requestClientId, encryptUniqueId, timestamp: timestamp2, signature } = request.fields;
10136
+ if (!requestClientId || !encryptUniqueId || !timestamp2 || !signature) {
10137
+ throw new UnlinkNotifyRejection(400, "Naver unlink notification is missing required parameters");
10138
+ }
10139
+ if (requestClientId !== clientId) {
10140
+ throw new UnlinkNotifyRejection(403, "Naver unlink notification clientId mismatch");
10141
+ }
10142
+ const key = deriveNaverUnlinkKey(clientSecret);
10143
+ const baseString = `clientId=${requestClientId}&encryptUniqueId=${encryptUniqueId}&timestamp=${timestamp2}`;
10144
+ const expected = createHmac("sha256", key).update(baseString).digest();
10145
+ const actual = decodeBase64Url(signature);
10146
+ if (expected.length !== actual.length || !timingSafeEqual2(expected, actual)) {
10147
+ throw new UnlinkNotifyRejection(403, "Naver unlink notification signature mismatch");
10148
+ }
10149
+ const uniqueId = decryptNaverUniqueId(encryptUniqueId, key);
10150
+ if (!uniqueId) {
10151
+ throw new UnlinkNotifyRejection(400, "Naver encryptUniqueId cannot be decrypted");
10152
+ }
10153
+ return { providerUserId: uniqueId, reason: "NAVER_UNLINK" };
10041
10154
  }
10042
10155
  };
10043
10156
  registerOAuthProvider(naverProvider);
@@ -10265,6 +10378,25 @@ async function getGoogleAccessToken(userId) {
10265
10378
  });
10266
10379
  return tokens.access_token;
10267
10380
  }
10381
+ async function oauthUnlinkNotifyService(provider, notification) {
10382
+ const oauthProvider = requireEnabledProvider(provider);
10383
+ const ackStatus = oauthProvider.unlinkNotifyAckStatus ?? 200;
10384
+ const account = await socialAccountsRepository.findByProviderAndProviderId(
10385
+ provider,
10386
+ notification.providerUserId
10387
+ );
10388
+ if (!account) {
10389
+ return { ackStatus, handled: false };
10390
+ }
10391
+ await socialAccountsRepository.deleteById(account.id);
10392
+ await oauthUnlinkedEvent.emit({
10393
+ userId: String(account.userId),
10394
+ provider,
10395
+ providerUserId: notification.providerUserId,
10396
+ reason: notification.reason
10397
+ });
10398
+ return { ackStatus, handled: true };
10399
+ }
10268
10400
 
10269
10401
  // src/server/services/oauth-native.service.ts
10270
10402
  init_repositories();
@@ -11558,6 +11690,56 @@ var oauthNative = route4.post("/_auth/oauth/:provider/native").input({
11558
11690
  const { params, body } = await c.data();
11559
11691
  return await oauthNativeService({ provider: params.provider, ...body });
11560
11692
  });
11693
+ async function collectUnlinkNotifyFields(raw) {
11694
+ const fields = { ...raw.req.query() };
11695
+ if (raw.req.method === "GET") {
11696
+ return fields;
11697
+ }
11698
+ const contentType = raw.req.header("content-type") ?? "";
11699
+ if (contentType.includes("application/json")) {
11700
+ const body2 = await raw.req.json().catch(() => null);
11701
+ for (const [key, value] of Object.entries(body2 ?? {})) {
11702
+ if (typeof value === "string" || typeof value === "number") {
11703
+ fields[key] = String(value);
11704
+ }
11705
+ }
11706
+ return fields;
11707
+ }
11708
+ const body = await raw.req.parseBody().catch(() => ({}));
11709
+ for (const [key, value] of Object.entries(body)) {
11710
+ if (typeof value === "string") {
11711
+ fields[key] = value;
11712
+ }
11713
+ }
11714
+ return fields;
11715
+ }
11716
+ async function processUnlinkNotify(provider, raw) {
11717
+ const oauthProvider = getOAuthProvider(provider);
11718
+ if (!oauthProvider?.isEnabled() || !oauthProvider.verifyUnlinkNotification) {
11719
+ return 404;
11720
+ }
11721
+ const request = {
11722
+ authorization: raw.req.header("authorization") ?? null,
11723
+ fields: await collectUnlinkNotifyFields(raw)
11724
+ };
11725
+ try {
11726
+ const notification = await oauthProvider.verifyUnlinkNotification(request);
11727
+ const result = await oauthUnlinkNotifyService(provider, notification);
11728
+ return result.ackStatus;
11729
+ } catch (error) {
11730
+ return error instanceof UnlinkNotifyRejection ? error.status : 400;
11731
+ }
11732
+ }
11733
+ var oauthUnlinkNotify = route4.post("/_auth/oauth/:provider/unlink-notify").input({ params: providerParams }).use([rateLimitPolicy4("oauth-unlink-notify", { limit: 300, windowMs: 6e4 })]).skip(["auth"]).handler(async (c) => {
11734
+ const { params } = await c.data();
11735
+ const status = await processUnlinkNotify(params.provider, c.raw);
11736
+ return status === 204 ? c.noContent() : c.json({}, status);
11737
+ });
11738
+ var oauthUnlinkNotifyGet = route4.get("/_auth/oauth/:provider/unlink-notify").input({ params: providerParams }).use([rateLimitPolicy4("oauth-unlink-notify", { limit: 300, windowMs: 6e4 })]).skip(["auth"]).handler(async (c) => {
11739
+ const { params } = await c.data();
11740
+ const status = await processUnlinkNotify(params.provider, c.raw);
11741
+ return status === 204 ? c.noContent() : c.json({}, status);
11742
+ });
11561
11743
  var oauthRouter = defineRouter4({
11562
11744
  oauthGoogleStart,
11563
11745
  oauthGoogleCallback,
@@ -11568,7 +11750,9 @@ var oauthRouter = defineRouter4({
11568
11750
  oauthProviderStart,
11569
11751
  oauthProviderCallback,
11570
11752
  getProviderOAuthUrl,
11571
- oauthNative
11753
+ oauthNative,
11754
+ oauthUnlinkNotify,
11755
+ oauthUnlinkNotifyGet
11572
11756
  });
11573
11757
 
11574
11758
  // src/server/routes/admin/index.ts
@@ -11729,6 +11913,8 @@ var mainAuthRouter = defineRouter6({
11729
11913
  oauthProviderCallback,
11730
11914
  getProviderOAuthUrl,
11731
11915
  oauthNative,
11916
+ oauthUnlinkNotify,
11917
+ oauthUnlinkNotifyGet,
11732
11918
  // Invitation routes
11733
11919
  getInvitation,
11734
11920
  acceptInvitation: acceptInvitation2,
@@ -12130,6 +12316,7 @@ export {
12130
12316
  SocialAccountsRepository,
12131
12317
  TargetTypeSchema,
12132
12318
  USER_STATUSES,
12319
+ UnlinkNotifyRejection,
12133
12320
  UserPermissionsRepository,
12134
12321
  UserProfilesRepository,
12135
12322
  UsersRepository,
@@ -12243,6 +12430,8 @@ export {
12243
12430
  oauthCallbackService,
12244
12431
  oauthNativeService,
12245
12432
  oauthStartService,
12433
+ oauthUnlinkNotifyService,
12434
+ oauthUnlinkedEvent,
12246
12435
  oneTimeTokenAuth,
12247
12436
  optionalAuth,
12248
12437
  parseDuration,