@spfn/auth 0.2.0-beta.87 → 0.2.0-beta.88

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.
Files changed (50) hide show
  1. package/README.md +333 -18
  2. package/dist/{authenticate-C7w-pLOR.d.ts → authenticate-ZssfqJ7d.d.ts} +150 -9
  3. package/dist/client-proof.d.ts +1 -1
  4. package/dist/client-proof.js +30 -0
  5. package/dist/client-proof.js.map +1 -1
  6. package/dist/config.d.ts +36 -0
  7. package/dist/config.js +18 -0
  8. package/dist/config.js.map +1 -1
  9. package/dist/errors.d.ts +93 -2
  10. package/dist/errors.js +57 -0
  11. package/dist/errors.js.map +1 -1
  12. package/dist/index.d.ts +36 -7
  13. package/dist/index.js +60 -0
  14. package/dist/index.js.map +1 -1
  15. package/dist/nextjs/server.d.ts +2 -2
  16. package/dist/server.d.ts +195 -80
  17. package/dist/server.js +515 -106
  18. package/dist/server.js.map +1 -1
  19. package/dist/{session-CGxgH3C9.d.ts → session-CFK4BT25.d.ts} +1 -1
  20. package/dist/{types-1BMx0OX1.d.ts → types-CD95yudz.d.ts} +15 -1
  21. package/migrations/20251125021229_premium_famine/snapshot.json +2641 -0
  22. package/migrations/20260225130050_smooth_the_fury/snapshot.json +2686 -0
  23. package/migrations/20260308141417_deep_iceman/snapshot.json +2686 -0
  24. package/migrations/20260308151309_perfect_deathbird/snapshot.json +2731 -0
  25. package/migrations/20260308201135_concerned_rawhide_kid/snapshot.json +2786 -0
  26. package/migrations/20260629103209_lethal_lifeguard/snapshot.json +2786 -0
  27. package/migrations/20260709073531_easy_hardball/snapshot.json +3119 -0
  28. package/migrations/20260714081434_glossy_major_mapleleaf/snapshot.json +3112 -0
  29. package/migrations/20260804105939_amazing_bushwacker/migration.sql +3 -0
  30. package/migrations/20260804105939_amazing_bushwacker/snapshot.json +3112 -0
  31. package/migrations/20260804110033_fat_piledriver/migration.sql +2 -0
  32. package/migrations/20260804110033_fat_piledriver/snapshot.json +3138 -0
  33. package/package.json +3 -6
  34. package/migrations/meta/0000_snapshot.json +0 -1632
  35. package/migrations/meta/0001_snapshot.json +0 -1660
  36. package/migrations/meta/0002_snapshot.json +0 -1660
  37. package/migrations/meta/0003_snapshot.json +0 -1689
  38. package/migrations/meta/0004_snapshot.json +0 -1721
  39. package/migrations/meta/0005_snapshot.json +0 -1721
  40. package/migrations/meta/0006_snapshot.json +0 -1921
  41. package/migrations/meta/0007_snapshot.json +0 -1916
  42. package/migrations/meta/_journal.json +0 -62
  43. /package/migrations/{0000_premium_famine.sql → 20251125021229_premium_famine/migration.sql} +0 -0
  44. /package/migrations/{0001_smooth_the_fury.sql → 20260225130050_smooth_the_fury/migration.sql} +0 -0
  45. /package/migrations/{0002_deep_iceman.sql → 20260308141417_deep_iceman/migration.sql} +0 -0
  46. /package/migrations/{0003_perfect_deathbird.sql → 20260308151309_perfect_deathbird/migration.sql} +0 -0
  47. /package/migrations/{0004_concerned_rawhide_kid.sql → 20260308201135_concerned_rawhide_kid/migration.sql} +0 -0
  48. /package/migrations/{0005_lethal_lifeguard.sql → 20260629103209_lethal_lifeguard/migration.sql} +0 -0
  49. /package/migrations/{0006_easy_hardball.sql → 20260709073531_easy_hardball/migration.sql} +0 -0
  50. /package/migrations/{0007_glossy_major_mapleleaf.sql → 20260714081434_glossy_major_mapleleaf/migration.sql} +0 -0
package/dist/server.js CHANGED
@@ -4454,13 +4454,31 @@ var init_esm = __esm({
4454
4454
  }
4455
4455
  });
4456
4456
 
4457
+ // src/server/types.ts
4458
+ var KEY_ALGORITHM, KEY_PLATFORM, KEY_DEVICE_NAME_MAX_LENGTH, INVITATION_STATUSES, USER_STATUSES, SOCIAL_PROVIDERS, ACCOUNT_DELETION_REQUEST_STATUSES, ACCOUNT_DELETION_REQUESTED_BY, PURGE_STRATEGIES;
4459
+ var init_types = __esm({
4460
+ "src/server/types.ts"() {
4461
+ "use strict";
4462
+ KEY_ALGORITHM = ["ES256", "RS256"];
4463
+ KEY_PLATFORM = ["ios", "android", "web", "desktop"];
4464
+ KEY_DEVICE_NAME_MAX_LENGTH = 64;
4465
+ INVITATION_STATUSES = ["pending", "accepted", "expired", "cancelled"];
4466
+ USER_STATUSES = ["active", "inactive", "suspended", "pending_deletion", "deleted"];
4467
+ SOCIAL_PROVIDERS = ["google", "apple", "github", "kakao", "naver", "superself"];
4468
+ ACCOUNT_DELETION_REQUEST_STATUSES = ["pending", "cancelled", "completed"];
4469
+ ACCOUNT_DELETION_REQUESTED_BY = ["self", "admin"];
4470
+ PURGE_STRATEGIES = ["anonymize", "hard-delete"];
4471
+ }
4472
+ });
4473
+
4457
4474
  // src/server/routes/schema.ts
4458
4475
  import { EMAIL_PATTERN, PHONE_PATTERN } from "@spfn/auth";
4459
- var EmailSchema, PhoneSchema, PasswordSchema, TargetTypeSchema, VERIFICATION_TARGET_TYPES, VerificationPurposeSchema, VERIFICATION_PURPOSES;
4476
+ var EmailSchema, PhoneSchema, DeviceNameSchema, PlatformSchema, PasswordSchema, TargetTypeSchema, VERIFICATION_TARGET_TYPES, VerificationPurposeSchema, VERIFICATION_PURPOSES;
4460
4477
  var init_schema3 = __esm({
4461
4478
  "src/server/routes/schema.ts"() {
4462
4479
  "use strict";
4463
4480
  init_esm();
4481
+ init_types();
4464
4482
  EmailSchema = Type.String({
4465
4483
  pattern: EMAIL_PATTERN,
4466
4484
  description: "Email address"
@@ -4469,6 +4487,14 @@ var init_schema3 = __esm({
4469
4487
  pattern: PHONE_PATTERN,
4470
4488
  description: "Phone number in E.164 format (e.g., +821012345678)"
4471
4489
  });
4490
+ DeviceNameSchema = Type.String({
4491
+ maxLength: KEY_DEVICE_NAME_MAX_LENGTH,
4492
+ description: `Device label shown in the key list (max ${KEY_DEVICE_NAME_MAX_LENGTH} chars)`
4493
+ });
4494
+ PlatformSchema = Type.Union(
4495
+ KEY_PLATFORM.map((platform) => Type.Literal(platform)),
4496
+ { description: "Platform the key lives on" }
4497
+ );
4472
4498
  PasswordSchema = Type.String({
4473
4499
  minLength: 8,
4474
4500
  maxLength: 72,
@@ -4495,21 +4521,6 @@ var init_schema3 = __esm({
4495
4521
  }
4496
4522
  });
4497
4523
 
4498
- // src/server/types.ts
4499
- var KEY_ALGORITHM, INVITATION_STATUSES, USER_STATUSES, SOCIAL_PROVIDERS, ACCOUNT_DELETION_REQUEST_STATUSES, ACCOUNT_DELETION_REQUESTED_BY, PURGE_STRATEGIES;
4500
- var init_types = __esm({
4501
- "src/server/types.ts"() {
4502
- "use strict";
4503
- KEY_ALGORITHM = ["ES256", "RS256"];
4504
- INVITATION_STATUSES = ["pending", "accepted", "expired", "cancelled"];
4505
- USER_STATUSES = ["active", "inactive", "suspended", "pending_deletion", "deleted"];
4506
- SOCIAL_PROVIDERS = ["google", "apple", "github", "kakao", "naver", "superself"];
4507
- ACCOUNT_DELETION_REQUEST_STATUSES = ["pending", "cancelled", "completed"];
4508
- ACCOUNT_DELETION_REQUESTED_BY = ["self", "admin"];
4509
- PURGE_STRATEGIES = ["anonymize", "hard-delete"];
4510
- }
4511
- });
4512
-
4513
4524
  // src/server/entities/schema.ts
4514
4525
  import { createSchema } from "@spfn/core/db";
4515
4526
  var authSchema;
@@ -4751,6 +4762,15 @@ var init_user_public_keys = __esm({
4751
4762
  // Format: hex-encoded string (64 chars)
4752
4763
  // Used for: duplicate detection, key verification
4753
4764
  fingerprint: text4("fingerprint").notNull(),
4765
+ // Device label the client supplied at registration
4766
+ // null: the client sent none (every key registered before this column existed)
4767
+ // Used for: telling one entry apart from another in the key list
4768
+ // Display only — nothing is authorized by it, so a client that lies gains nothing
4769
+ deviceName: text4("device_name"),
4770
+ // Platform the key lives on, as the client declared it
4771
+ // null: the client sent none
4772
+ // Used for: the same list, alongside deviceName
4773
+ platform: enumText2("platform", KEY_PLATFORM),
4754
4774
  // Key status
4755
4775
  // false: Key is deactivated (cannot be used for verification)
4756
4776
  // Used for: soft key rotation, temporary key suspension
@@ -5677,7 +5697,7 @@ var init_users_repository = __esm({
5677
5697
 
5678
5698
  // src/server/repositories/keys.repository.ts
5679
5699
  import { BaseRepository as BaseRepository2 } from "@spfn/core/db";
5680
- import { eq as eq2, and as and2, or, isNull, lt } from "drizzle-orm";
5700
+ import { eq as eq2, and as and2, or, isNull, lt, ne, desc } from "drizzle-orm";
5681
5701
  var LAST_USED_THROTTLE_MS, KeysRepository, keysRepository;
5682
5702
  var init_keys_repository = __esm({
5683
5703
  "src/server/repositories/keys.repository.ts"() {
@@ -5717,6 +5737,35 @@ var init_keys_repository = __esm({
5717
5737
  )
5718
5738
  );
5719
5739
  }
5740
+ /**
5741
+ * 키 목록 화면이 쓰는 공개키 조회 — 최근 등록순
5742
+ *
5743
+ * publicKey 원문은 고르지 않는다. 목록의 용도는 기기를 알아보고 지목하는 것이고,
5744
+ * 공개키는 그 어느 쪽에도 필요 없다. fingerprint는 호출자가 잘라서 내보낸다.
5745
+ *
5746
+ * `includeRevoked`는 이미 끊은 기기까지 보여준다 — "내가 언제 무엇을 끊었나"를
5747
+ * 확인하는 용도라, 폐기 시각과 사유를 함께 고른다.
5748
+ * Read replica 사용
5749
+ */
5750
+ async listForUser(userId, includeRevoked = false) {
5751
+ return this.readDb.select({
5752
+ keyId: userPublicKeys.keyId,
5753
+ deviceName: userPublicKeys.deviceName,
5754
+ platform: userPublicKeys.platform,
5755
+ algorithm: userPublicKeys.algorithm,
5756
+ fingerprint: userPublicKeys.fingerprint,
5757
+ isActive: userPublicKeys.isActive,
5758
+ createdAt: userPublicKeys.createdAt,
5759
+ lastUsedAt: userPublicKeys.lastUsedAt,
5760
+ expiresAt: userPublicKeys.expiresAt,
5761
+ revokedAt: userPublicKeys.revokedAt
5762
+ }).from(userPublicKeys).where(
5763
+ includeRevoked ? eq2(userPublicKeys.userId, userId) : and2(
5764
+ eq2(userPublicKeys.userId, userId),
5765
+ eq2(userPublicKeys.isActive, true)
5766
+ )
5767
+ ).orderBy(desc(userPublicKeys.createdAt));
5768
+ }
5720
5769
  /**
5721
5770
  * 공개키 생성
5722
5771
  * Write primary 사용
@@ -5763,6 +5812,26 @@ var init_keys_repository = __esm({
5763
5812
  )
5764
5813
  ).returning();
5765
5814
  }
5815
+ /**
5816
+ * 지정한 키 하나만 남기고 사용자의 활성 공개키를 전부 revoke
5817
+ *
5818
+ * "다른 기기 전부 로그아웃" — 요청을 보낸 기기는 살려 둔다. 남길 키를 별도 조회로
5819
+ * 확인하지 않고 조건에 담아, 그 사이에 다른 요청이 키를 바꾸는 경쟁을 만들지 않는다.
5820
+ * Write primary 사용
5821
+ */
5822
+ async revokeAllActiveByUserIdExcept(userId, keepKeyId, reason) {
5823
+ return await this.db.update(userPublicKeys).set({
5824
+ isActive: false,
5825
+ revokedAt: /* @__PURE__ */ new Date(),
5826
+ revokedReason: reason
5827
+ }).where(
5828
+ and2(
5829
+ eq2(userPublicKeys.userId, userId),
5830
+ eq2(userPublicKeys.isActive, true),
5831
+ ne(userPublicKeys.keyId, keepKeyId)
5832
+ )
5833
+ ).returning();
5834
+ }
5766
5835
  /**
5767
5836
  * 공개키 삭제
5768
5837
  * Write primary 사용
@@ -5802,6 +5871,19 @@ var init_keys_repository = __esm({
5802
5871
  ).returning();
5803
5872
  return result[0] ?? null;
5804
5873
  }
5874
+ /**
5875
+ * 만료 시각 연장 — 같은 사용자가 로그인으로 신원을 다시 증명했을 때 쓴다.
5876
+ * Write primary 사용
5877
+ */
5878
+ async extendExpiry(keyId, userId, expiresAt) {
5879
+ const result = await this.db.update(userPublicKeys).set({ expiresAt }).where(
5880
+ and2(
5881
+ eq2(userPublicKeys.keyId, keyId),
5882
+ eq2(userPublicKeys.userId, userId)
5883
+ )
5884
+ ).returning();
5885
+ return result[0] ?? null;
5886
+ }
5805
5887
  /**
5806
5888
  * Key ID로 공개키 조회 — 활성 여부 무관 (clientProofV1 admission의 revocation 판정용)
5807
5889
  *
@@ -6429,7 +6511,7 @@ var init_user_profiles_repository = __esm({
6429
6511
  });
6430
6512
 
6431
6513
  // src/server/repositories/invitations.repository.ts
6432
- import { eq as eq9, and as and6, lt as lt4, desc, sql as sql3 } from "drizzle-orm";
6514
+ import { eq as eq9, and as and6, lt as lt4, desc as desc2, sql as sql3 } from "drizzle-orm";
6433
6515
  import { BaseRepository as BaseRepository9 } from "@spfn/core/db";
6434
6516
  var InvitationsRepository, invitationsRepository;
6435
6517
  var init_invitations_repository = __esm({
@@ -6595,7 +6677,7 @@ var init_invitations_repository = __esm({
6595
6677
  id: users.id,
6596
6678
  email: users.email
6597
6679
  }
6598
- }).from(userInvitations).innerJoin(roles, eq9(userInvitations.roleId, roles.id)).innerJoin(users, eq9(userInvitations.invitedBy, users.id)).where(whereClause).orderBy(desc(userInvitations.createdAt)).limit(limit).offset(offset);
6680
+ }).from(userInvitations).innerJoin(roles, eq9(userInvitations.roleId, roles.id)).innerJoin(users, eq9(userInvitations.invitedBy, users.id)).where(whereClause).orderBy(desc2(userInvitations.createdAt)).limit(limit).offset(offset);
6599
6681
  return {
6600
6682
  invitations: results,
6601
6683
  total,
@@ -7052,6 +7134,24 @@ var init_schema5 = __esm({
7052
7134
  ]
7053
7135
  })
7054
7136
  },
7137
+ SPFN_AUTH_KAKAO_NATIVE_CLIENT_IDS: {
7138
+ ...envString({
7139
+ description: "Comma-separated Kakao app keys accepted as id_token audience for native sign-in (native app key). SPFN_AUTH_KAKAO_CLIENT_ID (REST API key) is also accepted automatically, so native sign-in is available when either variable is set. Requires OpenID Connect to be enabled in the Kakao developer console.",
7140
+ required: false,
7141
+ examples: [
7142
+ "your-kakao-native-app-key"
7143
+ ]
7144
+ })
7145
+ },
7146
+ SPFN_AUTH_NAVER_NATIVE_CLIENT_IDS: {
7147
+ ...envString({
7148
+ description: "Comma-separated Naver client IDs accepted as id_token audience for native sign-in. SPFN_AUTH_NAVER_CLIENT_ID is also accepted automatically, and one Naver application has a single client ID covering web and app environments \u2014 set this only when the app uses a separate application.",
7149
+ required: false,
7150
+ examples: [
7151
+ "your-naver-app-client-id"
7152
+ ]
7153
+ })
7154
+ },
7055
7155
  SPFN_AUTH_OAUTH_SUCCESS_URL: {
7056
7156
  ...envString({
7057
7157
  description: "OAuth callback page URL. This page should use OAuthCallback component to finalize session.",
@@ -7881,6 +7981,7 @@ function getKeyId(c) {
7881
7981
 
7882
7982
  // src/server/routes/auth/index.ts
7883
7983
  init_types();
7984
+ import { KeyNotFoundError } from "@spfn/auth/errors";
7884
7985
 
7885
7986
  // src/server/services/auth.service.ts
7886
7987
  init_repositories();
@@ -8174,17 +8275,27 @@ var KEY_TTL_DAYS = 90;
8174
8275
 
8175
8276
  // src/server/services/key.service.ts
8176
8277
  init_repositories();
8177
- import { InvalidKeyFingerprintError } from "@spfn/auth/errors";
8278
+ import { InvalidKeyFingerprintError, KeyIdAlreadyRegisteredError } from "@spfn/auth/errors";
8279
+ var KEY_FINGERPRINT_PREFIX_LENGTH = 8;
8178
8280
  function getKeyExpiryDate() {
8179
8281
  const expiresAt = /* @__PURE__ */ new Date();
8180
8282
  expiresAt.setDate(expiresAt.getDate() + KEY_TTL_DAYS);
8181
8283
  return expiresAt;
8182
8284
  }
8285
+ function isExpired(expiresAt) {
8286
+ return expiresAt !== null && /* @__PURE__ */ new Date() > expiresAt;
8287
+ }
8183
8288
  async function registerPublicKeyService(params) {
8184
- const { userId, keyId, publicKey, fingerprint, algorithm = "ES256" } = params;
8185
- const existing = await keysRepository.findActiveByKeyId(keyId);
8289
+ const { userId, keyId, publicKey, fingerprint, algorithm = "ES256", deviceName, platform } = params;
8290
+ const existing = await keysRepository.findByKeyId(keyId);
8186
8291
  if (existing) {
8187
- return;
8292
+ if (existing.userId === userId && existing.isActive) {
8293
+ if (isExpired(existing.expiresAt)) {
8294
+ await keysRepository.extendExpiry(keyId, userId, getKeyExpiryDate());
8295
+ }
8296
+ return;
8297
+ }
8298
+ throw new KeyIdAlreadyRegisteredError();
8188
8299
  }
8189
8300
  const isValidFingerprint = verifyKeyFingerprint(publicKey, fingerprint);
8190
8301
  if (!isValidFingerprint) {
@@ -8196,6 +8307,8 @@ async function registerPublicKeyService(params) {
8196
8307
  publicKey,
8197
8308
  algorithm,
8198
8309
  fingerprint,
8310
+ deviceName,
8311
+ platform,
8199
8312
  isActive: true,
8200
8313
  createdAt: /* @__PURE__ */ new Date(),
8201
8314
  expiresAt: getKeyExpiryDate()
@@ -8207,6 +8320,7 @@ async function rotateKeyService(params) {
8207
8320
  if (!isValidFingerprint) {
8208
8321
  throw new InvalidKeyFingerprintError();
8209
8322
  }
8323
+ const replaced = await keysRepository.findByKeyIdAndUserId(oldKeyId, userId);
8210
8324
  await keysRepository.revokeByKeyIdAndUserId(
8211
8325
  oldKeyId,
8212
8326
  userId,
@@ -8218,6 +8332,8 @@ async function rotateKeyService(params) {
8218
8332
  publicKey: newPublicKey,
8219
8333
  algorithm,
8220
8334
  fingerprint,
8335
+ deviceName: params.deviceName ?? replaced?.deviceName ?? void 0,
8336
+ platform: params.platform ?? replaced?.platform ?? void 0,
8221
8337
  isActive: true,
8222
8338
  createdAt: /* @__PURE__ */ new Date(),
8223
8339
  expiresAt: getKeyExpiryDate()
@@ -8229,7 +8345,29 @@ async function rotateKeyService(params) {
8229
8345
  }
8230
8346
  async function revokeKeyService(params) {
8231
8347
  const { userId, keyId, reason } = params;
8232
- await keysRepository.revokeByKeyIdAndUserId(keyId, userId, reason);
8348
+ const revoked = await keysRepository.revokeByKeyIdAndUserId(keyId, userId, reason);
8349
+ return revoked !== null;
8350
+ }
8351
+ async function listKeysService(params) {
8352
+ const rows = await keysRepository.listForUser(params.userId, params.includeRevoked);
8353
+ return rows.map((row) => ({
8354
+ keyId: row.keyId,
8355
+ deviceName: row.deviceName ?? void 0,
8356
+ platform: row.platform ?? void 0,
8357
+ algorithm: row.algorithm,
8358
+ fingerprintPrefix: row.fingerprint.slice(0, KEY_FINGERPRINT_PREFIX_LENGTH),
8359
+ createdAt: row.createdAt.toISOString(),
8360
+ lastUsedAt: row.lastUsedAt?.toISOString(),
8361
+ expiresAt: row.expiresAt?.toISOString(),
8362
+ isExpired: isExpired(row.expiresAt),
8363
+ isActive: row.isActive,
8364
+ revokedAt: row.revokedAt?.toISOString()
8365
+ }));
8366
+ }
8367
+ async function revokeAllKeysService(params) {
8368
+ const { userId, currentKeyId, includeCurrent = false, reason } = params;
8369
+ const revoked = includeCurrent ? await keysRepository.revokeAllActiveByUserId(userId, reason) : await keysRepository.revokeAllActiveByUserIdExcept(userId, currentKeyId, reason);
8370
+ return { revokedCount: revoked.length, currentKeyRevoked: includeCurrent };
8233
8371
  }
8234
8372
 
8235
8373
  // src/server/services/user.service.ts
@@ -8755,7 +8893,9 @@ async function registerService(params) {
8755
8893
  keyId,
8756
8894
  publicKey,
8757
8895
  fingerprint,
8758
- algorithm
8896
+ algorithm,
8897
+ deviceName: params.deviceName,
8898
+ platform: params.platform
8759
8899
  });
8760
8900
  const result = {
8761
8901
  userId: String(newUser.id),
@@ -8805,7 +8945,9 @@ async function loginService(params) {
8805
8945
  keyId,
8806
8946
  publicKey,
8807
8947
  fingerprint,
8808
- algorithm
8948
+ algorithm,
8949
+ deviceName: params.deviceName,
8950
+ platform: params.platform
8809
8951
  });
8810
8952
  await updateLastLoginService(user.id);
8811
8953
  const result = {
@@ -9316,7 +9458,7 @@ function getOneTimeTokenManager() {
9316
9458
  async function issueOneTimeTokenService(userId) {
9317
9459
  const manager2 = getOneTimeTokenManager();
9318
9460
  const token = await manager2.issue(userId);
9319
- const expiresAt = new Date(Date.now() + 3e4).toISOString();
9461
+ const expiresAt = new Date(Date.now() + manager2.ttlMs).toISOString();
9320
9462
  return { token, expiresAt };
9321
9463
  }
9322
9464
  async function verifyOneTimeTokenService(token) {
@@ -9410,8 +9552,12 @@ async function updateUserProfileService(userId, params) {
9410
9552
  // src/server/services/oauth.service.ts
9411
9553
  init_repositories();
9412
9554
  import { env as env11 } from "@spfn/auth/config";
9413
- import { ValidationError as ValidationError9 } from "@spfn/core/errors";
9414
- import { AccountDisabledError as AccountDisabledError2, AccountPendingDeletionError as AccountPendingDeletionError2 } from "@spfn/auth/errors";
9555
+ import { ValidationError as ValidationError8 } from "@spfn/core/errors";
9556
+ import {
9557
+ AccountDisabledError as AccountDisabledError2,
9558
+ AccountPendingDeletionError as AccountPendingDeletionError2,
9559
+ UnverifiedEmailLinkError
9560
+ } from "@spfn/auth/errors";
9415
9561
 
9416
9562
  // src/server/lib/oauth/google.ts
9417
9563
  import { env as env7 } from "@spfn/auth/config";
@@ -9576,6 +9722,7 @@ function getRegisteredProviders() {
9576
9722
  import { createRemoteJWKSet, jwtVerify } from "jose";
9577
9723
  import { InvalidSocialTokenError } from "@spfn/auth/errors";
9578
9724
  var CLOCK_TOLERANCE_SECONDS = 30;
9725
+ var MAX_TOKEN_AGE_SECONDS = 600;
9579
9726
  var jwksByUri = /* @__PURE__ */ new Map();
9580
9727
  function getRemoteJwks(jwksUri) {
9581
9728
  const cached = jwksByUri.get(jwksUri);
@@ -9602,7 +9749,8 @@ async function verifySignature(params) {
9602
9749
  issuer: params.issuer,
9603
9750
  audience: params.audiences,
9604
9751
  algorithms: params.algorithms,
9605
- clockTolerance: CLOCK_TOLERANCE_SECONDS
9752
+ clockTolerance: CLOCK_TOLERANCE_SECONDS,
9753
+ maxTokenAge: MAX_TOKEN_AGE_SECONDS
9606
9754
  });
9607
9755
  return payload;
9608
9756
  } catch (err) {
@@ -9618,7 +9766,7 @@ init_token_cipher();
9618
9766
 
9619
9767
  // src/server/lib/oauth/google-provider.ts
9620
9768
  import { env as env9 } from "@spfn/auth/config";
9621
- import { ValidationError as ValidationError4 } from "@spfn/core/errors";
9769
+ import { NativeSignInUnsupportedError } from "@spfn/auth/errors";
9622
9770
  var GOOGLE_JWKS_URI = "https://www.googleapis.com/oauth2/v3/certs";
9623
9771
  var GOOGLE_ISSUERS = ["https://accounts.google.com", "accounts.google.com"];
9624
9772
  function getGoogleNativeAudiences() {
@@ -9661,7 +9809,7 @@ var googleProvider = {
9661
9809
  async verifyNativeIdToken(idToken, options) {
9662
9810
  const audiences = getGoogleNativeAudiences();
9663
9811
  if (audiences.length === 0) {
9664
- throw new ValidationError4({
9812
+ throw new NativeSignInUnsupportedError({
9665
9813
  message: "Google native sign-in is not configured. Set SPFN_AUTH_GOOGLE_NATIVE_CLIENT_IDS."
9666
9814
  });
9667
9815
  }
@@ -9687,7 +9835,8 @@ registerOAuthProvider(googleProvider);
9687
9835
  // src/server/lib/oauth/apple-provider.ts
9688
9836
  import { createHash as createHash2 } from "crypto";
9689
9837
  import { env as env10 } from "@spfn/auth/config";
9690
- import { ValidationError as ValidationError5 } from "@spfn/core/errors";
9838
+ import { ValidationError as ValidationError4 } from "@spfn/core/errors";
9839
+ import { NativeSignInUnsupportedError as NativeSignInUnsupportedError2 } from "@spfn/auth/errors";
9691
9840
  var APPLE_JWKS_URI = "https://appleid.apple.com/auth/keys";
9692
9841
  var APPLE_ISSUER = "https://appleid.apple.com";
9693
9842
  function getAppleClientIds() {
@@ -9697,7 +9846,7 @@ function hashNonce(rawNonce) {
9697
9846
  return createHash2("sha256").update(rawNonce).digest("hex");
9698
9847
  }
9699
9848
  function unsupportedWebFlow() {
9700
- throw new ValidationError5({
9849
+ throw new ValidationError4({
9701
9850
  message: "Apple provider supports native id_token sign-in only. Use POST /_auth/oauth/apple/native."
9702
9851
  });
9703
9852
  }
@@ -9718,7 +9867,7 @@ var appleProvider = {
9718
9867
  async verifyNativeIdToken(idToken, options) {
9719
9868
  const audiences = getAppleClientIds();
9720
9869
  if (audiences.length === 0) {
9721
- throw new ValidationError5({
9870
+ throw new NativeSignInUnsupportedError2({
9722
9871
  message: "Apple native sign-in is not configured. Set SPFN_AUTH_APPLE_CLIENT_IDS."
9723
9872
  });
9724
9873
  }
@@ -9741,7 +9890,7 @@ registerOAuthProvider(appleProvider);
9741
9890
 
9742
9891
  // src/server/lib/oauth/github-provider.ts
9743
9892
  init_config();
9744
- import { ValidationError as ValidationError6 } from "@spfn/core/errors";
9893
+ import { ValidationError as ValidationError5 } from "@spfn/core/errors";
9745
9894
  var GITHUB_AUTH_URL = "https://github.com/login/oauth/authorize";
9746
9895
  var GITHUB_TOKEN_URL = "https://github.com/login/oauth/access_token";
9747
9896
  var GITHUB_USERINFO_URL = "https://api.github.com/user";
@@ -9752,7 +9901,7 @@ function getGithubConfig() {
9752
9901
  const clientId = env3.SPFN_AUTH_GITHUB_CLIENT_ID;
9753
9902
  const clientSecret = env3.SPFN_AUTH_GITHUB_CLIENT_SECRET;
9754
9903
  if (!clientId || !clientSecret) {
9755
- throw new ValidationError6({
9904
+ throw new ValidationError5({
9756
9905
  message: "GitHub OAuth is not configured. Set SPFN_AUTH_GITHUB_CLIENT_ID and SPFN_AUTH_GITHUB_CLIENT_SECRET."
9757
9906
  });
9758
9907
  }
@@ -9874,16 +10023,20 @@ registerOAuthProvider(githubProvider);
9874
10023
 
9875
10024
  // src/server/lib/oauth/kakao-provider.ts
9876
10025
  init_config();
9877
- import { ValidationError as ValidationError7 } from "@spfn/core/errors";
10026
+ import { ValidationError as ValidationError6 } from "@spfn/core/errors";
10027
+ import { NativeSignInUnsupportedError as NativeSignInUnsupportedError3 } from "@spfn/auth/errors";
9878
10028
  import { timingSafeEqual } from "crypto";
9879
10029
  var KAKAO_AUTH_URL = "https://kauth.kakao.com/oauth/authorize";
9880
10030
  var KAKAO_TOKEN_URL = "https://kauth.kakao.com/oauth/token";
9881
10031
  var KAKAO_USERINFO_URL = "https://kapi.kakao.com/v2/user/me";
10032
+ var KAKAO_ISSUER = "https://kauth.kakao.com";
10033
+ var KAKAO_JWKS_URI = "https://kauth.kakao.com/.well-known/jwks.json";
10034
+ var USERINFO_TIMEOUT_MS = 5e3;
9882
10035
  function getKakaoConfig() {
9883
10036
  const clientId = env3.SPFN_AUTH_KAKAO_CLIENT_ID;
9884
10037
  const clientSecret = env3.SPFN_AUTH_KAKAO_CLIENT_SECRET;
9885
10038
  if (!clientId) {
9886
- throw new ValidationError7({
10039
+ throw new ValidationError6({
9887
10040
  message: "Kakao OAuth is not configured. Set SPFN_AUTH_KAKAO_CLIENT_ID."
9888
10041
  });
9889
10042
  }
@@ -9898,6 +10051,13 @@ function getKakaoScopes() {
9898
10051
  const configured2 = env3.SPFN_AUTH_KAKAO_SCOPES;
9899
10052
  return configured2 ? configured2.split(",").map((scope) => scope.trim()).filter(Boolean) : ["account_email"];
9900
10053
  }
10054
+ function getKakaoNativeAudiences() {
10055
+ const ids = (env3.SPFN_AUTH_KAKAO_NATIVE_CLIENT_IDS || "").split(",").map((s) => s.trim()).filter(Boolean);
10056
+ if (env3.SPFN_AUTH_KAKAO_CLIENT_ID) {
10057
+ ids.push(env3.SPFN_AUTH_KAKAO_CLIENT_ID);
10058
+ }
10059
+ return ids;
10060
+ }
9901
10061
  async function requestKakaoTokens(params) {
9902
10062
  const response = await fetch(KAKAO_TOKEN_URL, {
9903
10063
  method: "POST",
@@ -9918,8 +10078,61 @@ async function requestKakaoTokens(params) {
9918
10078
  expiresIn
9919
10079
  };
9920
10080
  }
10081
+ async function fetchKakaoIdentity(accessToken) {
10082
+ const response = await fetch(KAKAO_USERINFO_URL, {
10083
+ headers: { Authorization: `Bearer ${accessToken}` },
10084
+ signal: AbortSignal.timeout(USERINFO_TIMEOUT_MS)
10085
+ });
10086
+ if (!response.ok) {
10087
+ throw new Error(`Kakao user-info request failed with status ${response.status}`);
10088
+ }
10089
+ const body = await response.json();
10090
+ if (typeof body.id !== "number" && typeof body.id !== "string") {
10091
+ throw new Error("Kakao user-info response is missing the provider user ID");
10092
+ }
10093
+ const account = body.kakao_account;
10094
+ const email = typeof account?.email === "string" ? account.email : null;
10095
+ return {
10096
+ providerUserId: String(body.id),
10097
+ email,
10098
+ emailVerified: email !== null && account?.is_email_valid === true && account.is_email_verified === true,
10099
+ name: typeof account?.profile?.nickname === "string" ? account.profile.nickname : void 0,
10100
+ avatar: typeof account?.profile?.profile_image_url === "string" ? account.profile.profile_image_url : void 0
10101
+ };
10102
+ }
10103
+ async function withKakaoVerifiedEmail(identity, accessToken) {
10104
+ const fetched = await fetchKakaoIdentity(accessToken).catch((err) => {
10105
+ authLogger.service.warn("Kakao user-info lookup failed; continuing without email verification", {
10106
+ reason: err instanceof Error ? err.message : "unknown"
10107
+ });
10108
+ return null;
10109
+ });
10110
+ if (!fetched) {
10111
+ return identity;
10112
+ }
10113
+ if (fetched.providerUserId !== identity.providerUserId) {
10114
+ authLogger.service.warn("Kakao access token belongs to another user; ignoring it", {
10115
+ subject: identity.providerUserId
10116
+ });
10117
+ return identity;
10118
+ }
10119
+ return {
10120
+ ...fetched,
10121
+ email: fetched.email ?? identity.email,
10122
+ name: fetched.name ?? identity.name,
10123
+ avatar: fetched.avatar ?? identity.avatar
10124
+ };
10125
+ }
9921
10126
  var kakaoProvider = {
9922
10127
  id: "kakao",
10128
+ /**
10129
+ * 웹 authorization-code 흐름을 쓸 수 있는지만 판정한다.
10130
+ *
10131
+ * native 로그인은 이 값을 보지 않는다 — oauthNativeService는 verifyNativeIdToken의 존재만
10132
+ * 확인하고, 허용 audience는 그 메서드가 직접 검사한다. 네이티브 앱 키만 설정한 서비스에서
10133
+ * 이 값이 true가 되면 웹 흐름 게이트를 통과한 뒤 더 깊은 곳에서 REST API 키 부재로
10134
+ * 실패하므로, 판정을 웹 키에 그대로 둔다.
10135
+ */
9923
10136
  isEnabled() {
9924
10137
  return !!env3.SPFN_AUTH_KAKAO_CLIENT_ID;
9925
10138
  },
@@ -9948,25 +10161,41 @@ var kakaoProvider = {
9948
10161
  return requestKakaoTokens(params);
9949
10162
  },
9950
10163
  async getUserInfo(accessToken) {
9951
- const response = await fetch(KAKAO_USERINFO_URL, {
9952
- headers: { Authorization: `Bearer ${accessToken}` }
9953
- });
9954
- if (!response.ok) {
9955
- throw new Error(`Kakao user-info request failed with status ${response.status}`);
9956
- }
9957
- const body = await response.json();
9958
- if (typeof body.id !== "number" && typeof body.id !== "string") {
9959
- throw new Error("Kakao user-info response is missing the provider user ID");
10164
+ return fetchKakaoIdentity(accessToken);
10165
+ },
10166
+ /**
10167
+ * 네이티브/웹 SDK가 받은 카카오 id_token을 검증한다.
10168
+ *
10169
+ * 카카오는 표준 OIDC라 nonce를 raw 그대로 담는다(Apple 같은 SHA-256 해싱이 없다).
10170
+ * 앱에서 id_token을 받으려면 개발자 콘솔에서 OpenID Connect를 켜고 로그인 요청 scope에
10171
+ * openid를 넣어야 한다.
10172
+ *
10173
+ * emailVerified는 기본이 false다. id_token에는 이메일의 유효·인증 여부를 알려주는 claim이
10174
+ * 없어서, 웹 흐름과 같은 강도로 판정하려면 access token이 함께 와야 한다.
10175
+ */
10176
+ async verifyNativeIdToken(idToken, options) {
10177
+ const audiences = getKakaoNativeAudiences();
10178
+ if (audiences.length === 0) {
10179
+ throw new NativeSignInUnsupportedError3({
10180
+ message: "Kakao native sign-in is not configured. Set SPFN_AUTH_KAKAO_NATIVE_CLIENT_IDS."
10181
+ });
9960
10182
  }
9961
- const account = body.kakao_account;
9962
- const email = typeof account?.email === "string" ? account.email : null;
9963
- return {
9964
- providerUserId: String(body.id),
9965
- email,
9966
- emailVerified: email !== null && account?.is_email_valid === true && account.is_email_verified === true,
9967
- name: typeof account?.profile?.nickname === "string" ? account.profile.nickname : void 0,
9968
- avatar: typeof account?.profile?.profile_image_url === "string" ? account.profile.profile_image_url : void 0
10183
+ const payload = await verifyIdToken({
10184
+ idToken,
10185
+ jwksUri: KAKAO_JWKS_URI,
10186
+ issuer: KAKAO_ISSUER,
10187
+ audiences,
10188
+ algorithms: ["RS256"],
10189
+ expectedNonce: options.nonce
10190
+ });
10191
+ const identity = {
10192
+ providerUserId: payload.sub,
10193
+ email: typeof payload.email === "string" ? payload.email : null,
10194
+ emailVerified: false,
10195
+ name: typeof payload.nickname === "string" ? payload.nickname : void 0,
10196
+ avatar: typeof payload.picture === "string" ? payload.picture : void 0
9969
10197
  };
10198
+ return options.accessToken ? withKakaoVerifiedEmail(identity, options.accessToken) : identity;
9970
10199
  },
9971
10200
  async refreshTokens(refreshToken) {
9972
10201
  const config2 = getKakaoConfig();
@@ -10011,16 +10240,20 @@ registerOAuthProvider(kakaoProvider);
10011
10240
 
10012
10241
  // src/server/lib/oauth/naver-provider.ts
10013
10242
  init_config();
10014
- import { ValidationError as ValidationError8 } from "@spfn/core/errors";
10243
+ import { ValidationError as ValidationError7 } from "@spfn/core/errors";
10244
+ import { NativeSignInUnsupportedError as NativeSignInUnsupportedError4 } from "@spfn/auth/errors";
10015
10245
  import { createDecipheriv, createHash as createHash3, createHmac, timingSafeEqual as timingSafeEqual2 } from "crypto";
10016
10246
  var NAVER_AUTH_URL = "https://nid.naver.com/oauth2.0/authorize";
10017
10247
  var NAVER_TOKEN_URL = "https://nid.naver.com/oauth2.0/token";
10018
10248
  var NAVER_USERINFO_URL = "https://openapi.naver.com/v1/nid/me";
10249
+ var NAVER_ISSUER = "https://nid.naver.com";
10250
+ var NAVER_JWKS_URI = "https://nid.naver.com/oauth2/jwks";
10251
+ var USERINFO_TIMEOUT_MS2 = 5e3;
10019
10252
  function getNaverConfig() {
10020
10253
  const clientId = env3.SPFN_AUTH_NAVER_CLIENT_ID;
10021
10254
  const clientSecret = env3.SPFN_AUTH_NAVER_CLIENT_SECRET;
10022
10255
  if (!clientId || !clientSecret) {
10023
- throw new ValidationError8({
10256
+ throw new ValidationError7({
10024
10257
  message: "Naver OAuth is not configured. Set SPFN_AUTH_NAVER_CLIENT_ID and SPFN_AUTH_NAVER_CLIENT_SECRET."
10025
10258
  });
10026
10259
  }
@@ -10031,6 +10264,13 @@ function getNaverConfig() {
10031
10264
  redirectUri: env3.SPFN_AUTH_NAVER_REDIRECT_URI || `${baseUrl}/_auth/oauth/naver/callback`
10032
10265
  };
10033
10266
  }
10267
+ function getNaverNativeAudiences() {
10268
+ const ids = (env3.SPFN_AUTH_NAVER_NATIVE_CLIENT_IDS || "").split(",").map((s) => s.trim()).filter(Boolean);
10269
+ if (env3.SPFN_AUTH_NAVER_CLIENT_ID) {
10270
+ ids.push(env3.SPFN_AUTH_NAVER_CLIENT_ID);
10271
+ }
10272
+ return ids;
10273
+ }
10034
10274
  async function requestNaverTokens(params) {
10035
10275
  const response = await fetch(NAVER_TOKEN_URL, {
10036
10276
  method: "POST",
@@ -10070,6 +10310,53 @@ function decryptNaverUniqueId(encryptUniqueId, key) {
10070
10310
  return null;
10071
10311
  }
10072
10312
  }
10313
+ async function fetchNaverIdentity(accessToken) {
10314
+ const response = await fetch(NAVER_USERINFO_URL, {
10315
+ headers: { Authorization: `Bearer ${accessToken}` },
10316
+ signal: AbortSignal.timeout(USERINFO_TIMEOUT_MS2)
10317
+ });
10318
+ if (!response.ok) {
10319
+ throw new Error(`Naver user-info request failed with status ${response.status}`);
10320
+ }
10321
+ const body = await response.json();
10322
+ const profile = body.response;
10323
+ if (body.resultcode !== "00" || typeof profile?.id !== "string") {
10324
+ throw new Error("Naver user-info response is invalid");
10325
+ }
10326
+ return {
10327
+ providerUserId: profile.id,
10328
+ email: typeof profile.email === "string" ? profile.email : null,
10329
+ // 존재하면 검증된 것으로 취급한다. 응답에는 검증 플래그가 없고, 근거는 네이버가
10330
+ // 연락처 이메일을 바꿀 때 새 주소로 보낸 인증번호를 요구한다는 것 하나다. 그래서
10331
+ // 이 값은 소유가 확인된 주소이지 안정적인 식별자는 아니다 — 사용자가 바꿀 수 있고,
10332
+ // 하나의 주소를 최대 6개 아이디가 공유할 수 있으며, 아예 없을 수도 있다.
10333
+ // 계정을 가리키는 유일 키는 providerUserId뿐이다.
10334
+ //
10335
+ // 카카오와 근거가 다르다. 카카오는 응답의 is_email_valid·is_email_verified를
10336
+ // 직접 보고, 네이버는 변경 시점의 인증에 의존한다.
10337
+ emailVerified: typeof profile.email === "string",
10338
+ name: typeof profile.name === "string" ? profile.name : typeof profile.nickname === "string" ? profile.nickname : void 0,
10339
+ avatar: typeof profile.profile_image === "string" ? profile.profile_image : void 0
10340
+ };
10341
+ }
10342
+ async function withNaverProfile(identity, accessToken) {
10343
+ const fetched = await fetchNaverIdentity(accessToken).catch((err) => {
10344
+ authLogger.service.warn("Naver user-info lookup failed; continuing without email", {
10345
+ reason: err instanceof Error ? err.message : "unknown"
10346
+ });
10347
+ return null;
10348
+ });
10349
+ if (!fetched) {
10350
+ return identity;
10351
+ }
10352
+ if (fetched.providerUserId !== identity.providerUserId) {
10353
+ authLogger.service.warn("Naver access token belongs to another user; ignoring it", {
10354
+ subject: identity.providerUserId
10355
+ });
10356
+ return identity;
10357
+ }
10358
+ return fetched;
10359
+ }
10073
10360
  var naverProvider = {
10074
10361
  id: "naver",
10075
10362
  isEnabled() {
@@ -10097,26 +10384,39 @@ var naverProvider = {
10097
10384
  }));
10098
10385
  },
10099
10386
  async getUserInfo(accessToken) {
10100
- const response = await fetch(NAVER_USERINFO_URL, {
10101
- headers: { Authorization: `Bearer ${accessToken}` }
10102
- });
10103
- if (!response.ok) {
10104
- throw new Error(`Naver user-info request failed with status ${response.status}`);
10105
- }
10106
- const body = await response.json();
10107
- const profile = body.response;
10108
- if (body.resultcode !== "00" || typeof profile?.id !== "string") {
10109
- throw new Error("Naver user-info response is invalid");
10387
+ return fetchNaverIdentity(accessToken);
10388
+ },
10389
+ /**
10390
+ * 네이티브/웹 SDK가 받은 네이버 id_token을 검증한다.
10391
+ *
10392
+ * 표준 OIDC라 nonce는 raw 그대로 대조한다(Apple 같은 해싱이 없다). 다만 네이버는
10393
+ * nonce의 문자가 A일 때 그것을 떨어뜨려 돌려주므로, 클라이언트는 nonce를 소문자
10394
+ * hex로 만들어야 한다 — 문자집합에 A가 없어야 이 경우를 만나지 않는다. base64url은
10395
+ * 16바이트 값의 끝이 A·Q·g·w 하나라 4번에 1번, 대문자 hex는 16번에 1번 걸린다.
10396
+ *
10397
+ * id_token에는 이메일도 프로필도 없다. 이메일은 access token이 함께 왔을 때만 얻는다.
10398
+ */
10399
+ async verifyNativeIdToken(idToken, options) {
10400
+ const audiences = getNaverNativeAudiences();
10401
+ if (audiences.length === 0) {
10402
+ throw new NativeSignInUnsupportedError4({
10403
+ message: "Naver native sign-in is not configured. Set SPFN_AUTH_NAVER_NATIVE_CLIENT_IDS."
10404
+ });
10110
10405
  }
10111
- return {
10112
- providerUserId: profile.id,
10113
- email: typeof profile.email === "string" ? profile.email : null,
10114
- // 네이버 프로필 이메일은 네이버 계정 이메일이거나 인증 절차를 거친 연락처
10115
- // 이메일이다 — 존재하면 검증된 것으로 취급한다(카카오와 같은 신뢰 수준).
10116
- emailVerified: typeof profile.email === "string",
10117
- name: typeof profile.name === "string" ? profile.name : typeof profile.nickname === "string" ? profile.nickname : void 0,
10118
- avatar: typeof profile.profile_image === "string" ? profile.profile_image : void 0
10406
+ const payload = await verifyIdToken({
10407
+ idToken,
10408
+ jwksUri: NAVER_JWKS_URI,
10409
+ issuer: NAVER_ISSUER,
10410
+ audiences,
10411
+ algorithms: ["RS256"],
10412
+ expectedNonce: options.nonce
10413
+ });
10414
+ const identity = {
10415
+ providerUserId: payload.sub,
10416
+ email: null,
10417
+ emailVerified: false
10119
10418
  };
10419
+ return options.accessToken ? withNaverProfile(identity, options.accessToken) : identity;
10120
10420
  },
10121
10421
  async refreshTokens(refreshToken) {
10122
10422
  const config2 = getNaverConfig();
@@ -10174,12 +10474,12 @@ registerOAuthProvider(naverProvider);
10174
10474
  function requireEnabledProvider(provider) {
10175
10475
  const oauthProvider = getOAuthProvider(provider);
10176
10476
  if (!oauthProvider) {
10177
- throw new ValidationError9({
10477
+ throw new ValidationError8({
10178
10478
  message: `Unsupported OAuth provider: ${provider}. No provider is registered for this id.`
10179
10479
  });
10180
10480
  }
10181
10481
  if (!oauthProvider.isEnabled()) {
10182
- throw new ValidationError9({
10482
+ throw new ValidationError8({
10183
10483
  message: `OAuth provider '${provider}' is registered but not configured. Check its required environment variables.`
10184
10484
  });
10185
10485
  }
@@ -10187,7 +10487,7 @@ function requireEnabledProvider(provider) {
10187
10487
  }
10188
10488
  function tokenExpiryDate(expiresIn) {
10189
10489
  if (!Number.isFinite(expiresIn)) {
10190
- throw new ValidationError9({
10490
+ throw new ValidationError8({
10191
10491
  message: `Invalid token expiry returned from OAuth provider: ${expiresIn}`
10192
10492
  });
10193
10493
  }
@@ -10213,12 +10513,12 @@ async function oauthCallbackService(params) {
10213
10513
  const stateData = await verifyOAuthState(state);
10214
10514
  const nonceCandidates = typeof expectedNonce === "string" ? [expectedNonce] : expectedNonce ?? [];
10215
10515
  if (!stateData.nonce || !nonceCandidates.includes(stateData.nonce)) {
10216
- throw new ValidationError9({
10516
+ throw new ValidationError8({
10217
10517
  message: "OAuth state validation failed"
10218
10518
  });
10219
10519
  }
10220
10520
  if (stateData.provider !== provider) {
10221
- throw new ValidationError9({
10521
+ throw new ValidationError8({
10222
10522
  message: "OAuth state provider mismatch"
10223
10523
  });
10224
10524
  }
@@ -10285,7 +10585,7 @@ async function oauthCallbackService(params) {
10285
10585
  async function assertActiveForOAuthSession(userId) {
10286
10586
  const user = await usersRepository.findByIdOnPrimary(userId);
10287
10587
  if (!user) {
10288
- throw new ValidationError9({ message: "User not found" });
10588
+ throw new ValidationError8({ message: "User not found" });
10289
10589
  }
10290
10590
  if (user.status === "active") {
10291
10591
  return;
@@ -10326,9 +10626,7 @@ async function createOrLinkUser(provider, identity, tokens, metadata) {
10326
10626
  let isNewUser = false;
10327
10627
  if (existingUser) {
10328
10628
  if (!identity.emailVerified) {
10329
- throw new ValidationError9({
10330
- message: "Cannot link to existing account with unverified email. Please verify your email with the provider first."
10331
- });
10629
+ throw new UnverifiedEmailLinkError();
10332
10630
  }
10333
10631
  userId = existingUser.id;
10334
10632
  if (!existingUser.emailVerifiedAt) {
@@ -10397,16 +10695,16 @@ var TOKEN_EXPIRY_BUFFER_MS = 5 * 60 * 1e3;
10397
10695
  async function getGoogleAccessToken(userId) {
10398
10696
  const account = await socialAccountsRepository.findByUserIdAndProvider(userId, "google");
10399
10697
  if (!account) {
10400
- throw new ValidationError9({
10698
+ throw new ValidationError8({
10401
10699
  message: "No Google account linked. User must sign in with Google first."
10402
10700
  });
10403
10701
  }
10404
- const isExpired = !account.tokenExpiresAt || account.tokenExpiresAt.getTime() < Date.now() + TOKEN_EXPIRY_BUFFER_MS;
10405
- if (!isExpired && account.accessToken) {
10702
+ const isExpired2 = !account.tokenExpiresAt || account.tokenExpiresAt.getTime() < Date.now() + TOKEN_EXPIRY_BUFFER_MS;
10703
+ if (!isExpired2 && account.accessToken) {
10406
10704
  return account.accessToken;
10407
10705
  }
10408
10706
  if (!account.refreshToken) {
10409
- throw new ValidationError9({
10707
+ throw new ValidationError8({
10410
10708
  message: "Google refresh token not available. User must re-authenticate with Google."
10411
10709
  });
10412
10710
  }
@@ -10439,22 +10737,38 @@ async function oauthUnlinkNotifyService(provider, notification) {
10439
10737
  }
10440
10738
 
10441
10739
  // src/server/services/oauth-native.service.ts
10442
- init_repositories();
10443
- import { ValidationError as ValidationError10 } from "@spfn/core/errors";
10444
10740
  import { runInTransaction as runInTransaction2, onAfterCommit as onAfterCommit2 } from "@spfn/core/db";
10741
+ import {
10742
+ InvalidKeyFingerprintError as InvalidKeyFingerprintError2,
10743
+ NativeSignInUnsupportedError as NativeSignInUnsupportedError5,
10744
+ NonceKeyBindingError
10745
+ } from "@spfn/auth/errors";
10746
+ init_repositories();
10445
10747
  async function oauthNativeService(params) {
10446
10748
  const oauthProvider = getOAuthProvider(params.provider);
10447
10749
  if (!oauthProvider?.verifyNativeIdToken) {
10448
- throw new ValidationError10({
10750
+ throw new NativeSignInUnsupportedError5({
10449
10751
  message: `Provider '${params.provider}' does not support native id_token sign-in.`
10450
10752
  });
10451
10753
  }
10452
- const identity = await oauthProvider.verifyNativeIdToken(params.idToken, { nonce: params.nonce });
10754
+ assertNonceBindsPublicKey(params);
10755
+ const identity = await oauthProvider.verifyNativeIdToken(params.idToken, {
10756
+ nonce: params.nonce,
10757
+ accessToken: params.accessToken
10758
+ });
10453
10759
  if (params.profile?.name && !identity.name) {
10454
10760
  identity.name = params.profile.name;
10455
10761
  }
10456
10762
  return persistNativeLogin(identity, params);
10457
10763
  }
10764
+ function assertNonceBindsPublicKey(params) {
10765
+ if (params.nonce !== params.fingerprint) {
10766
+ throw new NonceKeyBindingError();
10767
+ }
10768
+ if (!verifyKeyFingerprint(params.publicKey, params.fingerprint)) {
10769
+ throw new InvalidKeyFingerprintError2();
10770
+ }
10771
+ }
10458
10772
  async function persistNativeLogin(identity, params) {
10459
10773
  return runInTransaction2(async () => {
10460
10774
  const existing = await socialAccountsRepository.findByProviderAndProviderId(
@@ -10477,7 +10791,9 @@ async function persistNativeLogin(identity, params) {
10477
10791
  keyId: params.keyId,
10478
10792
  publicKey: params.publicKey,
10479
10793
  fingerprint: params.fingerprint,
10480
- algorithm: params.algorithm
10794
+ algorithm: params.algorithm,
10795
+ deviceName: params.deviceName,
10796
+ platform: params.platform
10481
10797
  });
10482
10798
  await updateLastLoginService(userId);
10483
10799
  const eventPayload = {
@@ -10497,6 +10813,7 @@ import { Transactional } from "@spfn/core/db";
10497
10813
  import { rateLimitPolicy } from "@spfn/core/middleware";
10498
10814
 
10499
10815
  // src/server/lib/rate-limit-keys.ts
10816
+ import { createHash as createHash4 } from "crypto";
10500
10817
  import { getClientIp } from "@spfn/core/middleware";
10501
10818
  async function readJsonBody(c) {
10502
10819
  try {
@@ -10532,6 +10849,21 @@ function byIpAndAccount(options = {}) {
10532
10849
  ];
10533
10850
  };
10534
10851
  }
10852
+ function idTokenKey(body) {
10853
+ if (typeof body.idToken !== "string" || !body.idToken) {
10854
+ return void 0;
10855
+ }
10856
+ return `tok:${createHash4("sha256").update(body.idToken).digest("hex")}`;
10857
+ }
10858
+ function byIpAndIdToken(options = {}) {
10859
+ return async (c) => {
10860
+ const body = await readJsonBody(c);
10861
+ return [
10862
+ { key: `ip:${getClientIp(c)}`, limit: options.ipLimit },
10863
+ idTokenKey(body)
10864
+ ];
10865
+ };
10866
+ }
10535
10867
  function byIpAndTarget(options = {}) {
10536
10868
  return async (c) => {
10537
10869
  const body = await readJsonBody(c);
@@ -10596,7 +10928,9 @@ var register = route.post("/_auth/register").input({
10596
10928
  publicKey: Type.String({ description: "Client public key" }),
10597
10929
  keyId: Type.String({ description: "Key identifier" }),
10598
10930
  fingerprint: Type.String({ description: "Key fingerprint" }),
10599
- algorithm: Type.Union(KEY_ALGORITHM.map((algo) => Type.Literal(algo)), { description: "Signature algorithm" })
10931
+ algorithm: Type.Union(KEY_ALGORITHM.map((algo) => Type.Literal(algo)), { description: "Signature algorithm" }),
10932
+ deviceName: Type.Optional(DeviceNameSchema),
10933
+ platform: Type.Optional(PlatformSchema)
10600
10934
  })
10601
10935
  }).use([rateLimitPolicy("auth-register", { limit: 10, windowMs: 6e4, by: byIpAndAccount({ ipLimit: 100 }) }), Transactional()]).skip(["auth"]).handler(async (c) => {
10602
10936
  const { body } = await c.data();
@@ -10621,7 +10955,9 @@ var login = route.post("/_auth/login").input({
10621
10955
  keyId: Type.String({ description: "Key identifier" }),
10622
10956
  fingerprint: Type.String({ description: "Key fingerprint" }),
10623
10957
  algorithm: Type.Union(KEY_ALGORITHM.map((algo) => Type.Literal(algo)), { description: "Signature algorithm" }),
10624
- oldKeyId: Type.Optional(Type.String({ description: "Previous key ID for rotation" }))
10958
+ oldKeyId: Type.Optional(Type.String({ description: "Previous key ID for rotation" })),
10959
+ deviceName: Type.Optional(DeviceNameSchema),
10960
+ platform: Type.Optional(PlatformSchema)
10625
10961
  })
10626
10962
  }).use([rateLimitPolicy("auth-login", { limit: 10, windowMs: 6e4, by: byIpAndAccount({ ipLimit: 100 }) }), Transactional()]).skip(["auth"]).handler(async (c) => {
10627
10963
  const { body } = await c.data();
@@ -10641,7 +10977,9 @@ var rotateKey = route.post("/_auth/keys/rotate").interceptor({
10641
10977
  publicKey: Type.String({ description: "New public key" }),
10642
10978
  keyId: Type.String({ description: "New key identifier" }),
10643
10979
  fingerprint: Type.String({ description: "New key fingerprint" }),
10644
- algorithm: Type.Union(KEY_ALGORITHM.map((algo) => Type.Literal(algo)), { description: "Signature algorithm" })
10980
+ algorithm: Type.Union(KEY_ALGORITHM.map((algo) => Type.Literal(algo)), { description: "Signature algorithm" }),
10981
+ deviceName: Type.Optional(DeviceNameSchema),
10982
+ platform: Type.Optional(PlatformSchema)
10645
10983
  })
10646
10984
  }).use([Transactional()]).handler(async (c) => {
10647
10985
  const { body } = await c.data();
@@ -10652,7 +10990,53 @@ var rotateKey = route.post("/_auth/keys/rotate").interceptor({
10652
10990
  newKeyId: body.keyId,
10653
10991
  newPublicKey: body.publicKey,
10654
10992
  fingerprint: body.fingerprint,
10655
- algorithm: body.algorithm
10993
+ algorithm: body.algorithm,
10994
+ deviceName: body.deviceName,
10995
+ platform: body.platform
10996
+ });
10997
+ });
10998
+ var listKeys = route.post("/_auth/keys/list").input({
10999
+ body: Type.Object({
11000
+ includeRevoked: Type.Optional(Type.Boolean({
11001
+ description: "Also return keys already revoked, for reviewing what was cut off. Default false."
11002
+ }))
11003
+ })
11004
+ }).handler(async (c) => {
11005
+ const { body } = await c.data();
11006
+ const { userId } = getAuth(c);
11007
+ return { keys: await listKeysService({ userId: Number(userId), includeRevoked: body.includeRevoked }) };
11008
+ });
11009
+ var revokeKey = route.post("/_auth/keys/revoke").input({
11010
+ body: Type.Object({
11011
+ keyId: Type.String({ description: "Key to revoke \u2014 must belong to the caller" })
11012
+ })
11013
+ }).use([Transactional()]).handler(async (c) => {
11014
+ const { body } = await c.data();
11015
+ const { userId, keyId: currentKeyId } = getAuth(c);
11016
+ const revoked = await revokeKeyService({
11017
+ userId: Number(userId),
11018
+ keyId: body.keyId,
11019
+ reason: "Revoked by user"
11020
+ });
11021
+ if (!revoked) {
11022
+ throw new KeyNotFoundError();
11023
+ }
11024
+ return { keyId: body.keyId, selfRevoked: body.keyId === currentKeyId };
11025
+ });
11026
+ var revokeAllKeys = route.post("/_auth/keys/revoke-all").input({
11027
+ body: Type.Object({
11028
+ includeCurrent: Type.Optional(Type.Boolean({
11029
+ description: "Also revoke the key this request is signed with. Default false."
11030
+ }))
11031
+ })
11032
+ }).use([Transactional()]).handler(async (c) => {
11033
+ const { body } = await c.data();
11034
+ const { userId, keyId } = getAuth(c);
11035
+ return await revokeAllKeysService({
11036
+ userId: Number(userId),
11037
+ currentKeyId: keyId,
11038
+ includeCurrent: body.includeCurrent,
11039
+ reason: "Revoked by user"
10656
11040
  });
10657
11041
  });
10658
11042
  var changePassword = route.put("/_auth/password").input({
@@ -10689,6 +11073,9 @@ var authRouter = defineRouter({
10689
11073
  login,
10690
11074
  logout,
10691
11075
  rotateKey,
11076
+ listKeys,
11077
+ revokeKey,
11078
+ revokeAllKeys,
10692
11079
  changePassword,
10693
11080
  getAuthSession,
10694
11081
  issueOneTimeToken
@@ -11070,7 +11457,7 @@ function encodeString(value) {
11070
11457
  }
11071
11458
 
11072
11459
  // src/server/client-proof/proof.ts
11073
- import { createHash as createHash4, createPrivateKey, createPublicKey, sign, verify as verify2 } from "crypto";
11460
+ import { createHash as createHash5, createPrivateKey, createPublicKey, sign, verify as verify2 } from "crypto";
11074
11461
  var CLIENT_PROOF_PROFILE = "clientProofV1";
11075
11462
  var ABSENT_BODY_SHA256 = "0".repeat(64);
11076
11463
  var DEFAULT_REPLAY_WINDOW_MILLIS = 3e5;
@@ -11140,7 +11527,7 @@ function verifyClientProof(input, presentedProof, publicKey) {
11140
11527
  );
11141
11528
  }
11142
11529
  function sha256Hex(bytes) {
11143
- return createHash4("sha256").update(bytes).digest("hex");
11530
+ return createHash5("sha256").update(bytes).digest("hex");
11144
11531
  }
11145
11532
 
11146
11533
  // src/server/client-proof/refusal.ts
@@ -12275,8 +12662,9 @@ var deleteCookie = (c, name, opt) => {
12275
12662
 
12276
12663
  // src/server/routes/oauth/index.ts
12277
12664
  init_types();
12665
+ init_schema3();
12278
12666
  import { Transactional as Transactional3 } from "@spfn/core/db";
12279
- import { ValidationError as ValidationError11 } from "@spfn/core/errors";
12667
+ import { ValidationError as ValidationError9 } from "@spfn/core/errors";
12280
12668
  import { rateLimitPolicy as rateLimitPolicy4 } from "@spfn/core/middleware";
12281
12669
  import { defineRouter as defineRouter4, route as route4 } from "@spfn/core/route";
12282
12670
  var providerParams = Type.Object({
@@ -12396,10 +12784,10 @@ var getGoogleOAuthUrl = route4.post("/_auth/oauth/google/url").input({
12396
12784
  }).use([rateLimitPolicy4("oauth-start", { limit: 20, windowMs: 6e4 })]).skip(["auth"]).handler(async (c) => {
12397
12785
  const { body } = await c.data();
12398
12786
  if (!isGoogleOAuthEnabled()) {
12399
- throw new ValidationError11({ message: "Google OAuth is not configured" });
12787
+ throw new ValidationError9({ message: "Google OAuth is not configured" });
12400
12788
  }
12401
12789
  if (!body.state) {
12402
- throw new ValidationError11({
12790
+ throw new ValidationError9({
12403
12791
  message: "OAuth state is required. Ensure the OAuth interceptor is configured."
12404
12792
  });
12405
12793
  }
@@ -12494,7 +12882,7 @@ var getProviderOAuthUrl = route4.post("/_auth/oauth/:provider/url").input({
12494
12882
  const { params, body } = await c.data();
12495
12883
  const provider = requireEnabledProvider(params.provider);
12496
12884
  if (!body.state) {
12497
- throw new ValidationError11({
12885
+ throw new ValidationError9({
12498
12886
  message: "OAuth state is required. Ensure the OAuth interceptor is configured."
12499
12887
  });
12500
12888
  }
@@ -12504,13 +12892,20 @@ var oauthNative = route4.post("/_auth/oauth/:provider/native").input({
12504
12892
  params: providerParams,
12505
12893
  body: Type.Object({
12506
12894
  idToken: Type.String({ description: "id_token from native/web social SDK" }),
12507
- nonce: Type.String({ description: "Raw nonce used when requesting the id_token" }),
12895
+ nonce: Type.String({
12896
+ description: "Raw nonce used when requesting the id_token. Must equal `fingerprint` \u2014 the nonce is what binds the id_token to this public key, so a stolen id_token cannot be paired with a different key."
12897
+ }),
12898
+ accessToken: Type.Optional(Type.String({
12899
+ description: "Provider access token from the same sign-in. Optional \u2014 sent only when the provider needs it to resolve claims the id_token omits (Kakao email verification)."
12900
+ })),
12508
12901
  publicKey: Type.String({ description: "Client public key (Base64 DER)" }),
12509
12902
  keyId: Type.String({ description: "Key identifier (UUID)" }),
12510
12903
  fingerprint: Type.String({ description: "Key fingerprint (SHA-256 hex)" }),
12511
12904
  algorithm: Type.Union(KEY_ALGORITHM.map((a) => Type.Literal(a)), {
12512
12905
  description: "Key algorithm (ES256 or RS256)"
12513
12906
  }),
12907
+ deviceName: Type.Optional(DeviceNameSchema),
12908
+ platform: Type.Optional(PlatformSchema),
12514
12909
  profile: Type.Optional(Type.Object({
12515
12910
  name: Type.Optional(Type.String())
12516
12911
  }, {
@@ -12520,7 +12915,11 @@ var oauthNative = route4.post("/_auth/oauth/:provider/native").input({
12520
12915
  description: "Custom metadata passed to auth events (e.g. referral code, UTM params)"
12521
12916
  }))
12522
12917
  })
12523
- }).use([rateLimitPolicy4("oauth-native", { limit: 20, windowMs: 6e4 })]).skip(["auth"]).handler(async (c) => {
12918
+ }).use([rateLimitPolicy4("oauth-native", {
12919
+ limit: 5,
12920
+ windowMs: 6e4,
12921
+ by: byIpAndIdToken({ ipLimit: 20 })
12922
+ })]).skip(["auth"]).handler(async (c) => {
12524
12923
  const { params, body } = await c.data();
12525
12924
  return await oauthNativeService({ provider: params.provider, ...body });
12526
12925
  });
@@ -12729,6 +13128,9 @@ var mainAuthRouter = defineRouter6({
12729
13128
  login,
12730
13129
  logout,
12731
13130
  rotateKey,
13131
+ listKeys,
13132
+ revokeKey,
13133
+ revokeAllKeys,
12732
13134
  changePassword,
12733
13135
  getAuthSession,
12734
13136
  // One-Time Token routes
@@ -13134,16 +13536,21 @@ export {
13134
13536
  DEFAULT_DELETION_PURGE_CRON,
13135
13537
  DEFAULT_DELETION_PURGE_STRATEGY,
13136
13538
  DEFAULT_DELETION_SEND_NOTIFICATIONS,
13539
+ DeviceNameSchema,
13137
13540
  EmailSchema,
13138
13541
  EnvironmentKeyringTokenCipher,
13139
13542
  INVITATION_STATUSES,
13140
13543
  InvitationsRepository,
13141
13544
  KEY_ALGORITHM,
13545
+ KEY_DEVICE_NAME_MAX_LENGTH,
13546
+ KEY_FINGERPRINT_PREFIX_LENGTH,
13547
+ KEY_PLATFORM,
13142
13548
  KeysRepository,
13143
13549
  PURGE_STRATEGIES,
13144
13550
  PasswordSchema,
13145
13551
  PermissionsRepository,
13146
13552
  PhoneSchema,
13553
+ PlatformSchema,
13147
13554
  RolePermissionsRepository,
13148
13555
  RolesRepository,
13149
13556
  SOCIAL_PROVIDERS,
@@ -13257,6 +13664,7 @@ export {
13257
13664
  kakaoProvider,
13258
13665
  keysRepository,
13259
13666
  listInvitations,
13667
+ listKeysService,
13260
13668
  loginService,
13261
13669
  logoutService,
13262
13670
  matchOAuthCsrfCookies,
@@ -13284,6 +13692,7 @@ export {
13284
13692
  requireRole,
13285
13693
  resendInvitation,
13286
13694
  resolveAuthenticatedUser,
13695
+ revokeAllKeysService,
13287
13696
  revokeKeyService,
13288
13697
  roleGuard,
13289
13698
  rolePermissions,