@spfn/auth 0.2.0-beta.68 → 0.2.0-beta.69

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
@@ -4471,7 +4471,8 @@ var init_schema3 = __esm({
4471
4471
  });
4472
4472
  PasswordSchema = Type.String({
4473
4473
  minLength: 8,
4474
- description: "User password (minimum 8 characters)"
4474
+ maxLength: 72,
4475
+ description: "User password (8\u201372 characters). bcrypt silently ignores bytes past 72, so longer inputs are rejected rather than truncated."
4475
4476
  });
4476
4477
  TargetTypeSchema = Type.Union([
4477
4478
  Type.Literal("email"),
@@ -5590,12 +5591,13 @@ var init_users_repository = __esm({
5590
5591
 
5591
5592
  // src/server/repositories/keys.repository.ts
5592
5593
  import { BaseRepository as BaseRepository2 } from "@spfn/core/db";
5593
- import { eq as eq2, and as and2 } from "drizzle-orm";
5594
- var KeysRepository, keysRepository;
5594
+ import { eq as eq2, and as and2, or, isNull, lt } from "drizzle-orm";
5595
+ var LAST_USED_THROTTLE_MS, KeysRepository, keysRepository;
5595
5596
  var init_keys_repository = __esm({
5596
5597
  "src/server/repositories/keys.repository.ts"() {
5597
5598
  "use strict";
5598
5599
  init_user_public_keys();
5600
+ LAST_USED_THROTTLE_MS = 6e4;
5599
5601
  KeysRepository = class extends BaseRepository2 {
5600
5602
  /**
5601
5603
  * Key ID와 User ID로 공개키 조회
@@ -5699,13 +5701,24 @@ var init_keys_repository = __esm({
5699
5701
  }
5700
5702
  /**
5701
5703
  * Primary key로 마지막 사용 시간 업데이트 (authenticate용)
5702
- * Write primary 사용
5704
+ * Write primary 사용.
5705
+ *
5706
+ * Throttled: only writes when lastUsedAt is stale (older than
5707
+ * LAST_USED_THROTTLE_MS), so a busy key isn't UPDATEd on every request. The
5708
+ * throttle lives in the WHERE clause — atomic, no read-then-write race. No
5709
+ * RETURNING (callers fire-and-forget and discard the row).
5703
5710
  */
5704
5711
  async updateLastUsedById(id11) {
5705
- const result = await this.db.update(userPublicKeys).set({
5712
+ const staleBefore = new Date(Date.now() - LAST_USED_THROTTLE_MS);
5713
+ await this.db.update(userPublicKeys).set({
5706
5714
  lastUsedAt: /* @__PURE__ */ new Date()
5707
- }).where(eq2(userPublicKeys.id, id11)).returning();
5708
- return result[0] ?? null;
5715
+ }).where(and2(
5716
+ eq2(userPublicKeys.id, id11),
5717
+ or(
5718
+ isNull(userPublicKeys.lastUsedAt),
5719
+ lt(userPublicKeys.lastUsedAt, staleBefore)
5720
+ )
5721
+ ));
5709
5722
  }
5710
5723
  };
5711
5724
  keysRepository = new KeysRepository();
@@ -5714,7 +5727,7 @@ var init_keys_repository = __esm({
5714
5727
 
5715
5728
  // src/server/repositories/verification-codes.repository.ts
5716
5729
  import { BaseRepository as BaseRepository3 } from "@spfn/core/db";
5717
- import { eq as eq3, and as and3, lt, gt, isNull } from "drizzle-orm";
5730
+ import { eq as eq3, and as and3, lt as lt2, gt, isNull as isNull2 } from "drizzle-orm";
5718
5731
  var VerificationCodesRepository, verificationCodesRepository;
5719
5732
  var init_verification_codes_repository = __esm({
5720
5733
  "src/server/repositories/verification-codes.repository.ts"() {
@@ -5732,7 +5745,7 @@ var init_verification_codes_repository = __esm({
5732
5745
  and3(
5733
5746
  eq3(verificationCodes.target, target),
5734
5747
  eq3(verificationCodes.purpose, purpose),
5735
- isNull(verificationCodes.usedAt),
5748
+ isNull2(verificationCodes.usedAt),
5736
5749
  gt(verificationCodes.expiresAt, now)
5737
5750
  )
5738
5751
  ).limit(1);
@@ -5787,7 +5800,7 @@ var init_verification_codes_repository = __esm({
5787
5800
  */
5788
5801
  async deleteExpired() {
5789
5802
  const now = /* @__PURE__ */ new Date();
5790
- const result = await this.db.delete(verificationCodes).where(lt(verificationCodes.expiresAt, now)).returning();
5803
+ const result = await this.db.delete(verificationCodes).where(lt2(verificationCodes.expiresAt, now)).returning();
5791
5804
  return result.length;
5792
5805
  }
5793
5806
  /**
@@ -5802,7 +5815,7 @@ var init_verification_codes_repository = __esm({
5802
5815
  and3(
5803
5816
  eq3(verificationCodes.target, target),
5804
5817
  eq3(verificationCodes.purpose, purpose),
5805
- isNull(verificationCodes.usedAt)
5818
+ isNull2(verificationCodes.usedAt)
5806
5819
  )
5807
5820
  ).returning();
5808
5821
  return result.length;
@@ -6048,7 +6061,7 @@ var init_role_permissions_repository = __esm({
6048
6061
 
6049
6062
  // src/server/repositories/user-permissions.repository.ts
6050
6063
  import { BaseRepository as BaseRepository7 } from "@spfn/core/db";
6051
- import { eq as eq7, and as and5, or, isNull as isNull2, isNotNull, lt as lt2, gt as gt2 } from "drizzle-orm";
6064
+ import { eq as eq7, and as and5, or as or2, isNull as isNull3, isNotNull, lt as lt3, gt as gt2 } from "drizzle-orm";
6052
6065
  var UserPermissionsRepository, userPermissionsRepository;
6053
6066
  var init_user_permissions_repository = __esm({
6054
6067
  "src/server/repositories/user-permissions.repository.ts"() {
@@ -6070,8 +6083,8 @@ var init_user_permissions_repository = __esm({
6070
6083
  return this.readDb.select().from(userPermissions).where(
6071
6084
  and5(
6072
6085
  eq7(userPermissions.userId, userId),
6073
- or(
6074
- isNull2(userPermissions.expiresAt),
6086
+ or2(
6087
+ isNull3(userPermissions.expiresAt),
6075
6088
  gt2(userPermissions.expiresAt, now)
6076
6089
  )
6077
6090
  )
@@ -6133,7 +6146,7 @@ var init_user_permissions_repository = __esm({
6133
6146
  const result = await this.db.delete(userPermissions).where(
6134
6147
  and5(
6135
6148
  isNotNull(userPermissions.expiresAt),
6136
- lt2(userPermissions.expiresAt, now)
6149
+ lt3(userPermissions.expiresAt, now)
6137
6150
  )
6138
6151
  ).returning();
6139
6152
  return result.length;
@@ -6278,7 +6291,7 @@ var init_user_profiles_repository = __esm({
6278
6291
  });
6279
6292
 
6280
6293
  // src/server/repositories/invitations.repository.ts
6281
- import { eq as eq9, and as and6, lt as lt3, desc, sql as sql3 } from "drizzle-orm";
6294
+ import { eq as eq9, and as and6, lt as lt4, desc, sql as sql3 } from "drizzle-orm";
6282
6295
  import { BaseRepository as BaseRepository9 } from "@spfn/core/db";
6283
6296
  var InvitationsRepository, invitationsRepository;
6284
6297
  var init_invitations_repository = __esm({
@@ -6372,7 +6385,7 @@ var init_invitations_repository = __esm({
6372
6385
  }).where(
6373
6386
  and6(
6374
6387
  eq9(userInvitations.status, "pending"),
6375
- lt3(userInvitations.expiresAt, now)
6388
+ lt4(userInvitations.expiresAt, now)
6376
6389
  )
6377
6390
  ).returning();
6378
6391
  return result.length;
@@ -6847,7 +6860,7 @@ import { defineRouter as defineRouter5 } from "@spfn/core/route";
6847
6860
  init_schema3();
6848
6861
 
6849
6862
  // src/server/helpers/password.ts
6850
- import bcrypt from "bcryptjs";
6863
+ import * as bcrypt from "@node-rs/bcrypt";
6851
6864
  import { env } from "@spfn/auth/config";
6852
6865
  import { createPasswordParser } from "@spfn/core/env";
6853
6866
  async function hashPassword(password) {
@@ -6856,14 +6869,14 @@ async function hashPassword(password) {
6856
6869
  }
6857
6870
  return bcrypt.hash(password, env.SPFN_AUTH_BCRYPT_SALT_ROUNDS);
6858
6871
  }
6859
- async function verifyPassword(password, hash) {
6872
+ async function verifyPassword(password, hash2) {
6860
6873
  if (!password || password.length === 0) {
6861
6874
  throw new Error("Password cannot be empty");
6862
6875
  }
6863
- if (!hash || hash.length === 0) {
6876
+ if (!hash2 || hash2.length === 0) {
6864
6877
  throw new Error("Hash cannot be empty");
6865
6878
  }
6866
- return bcrypt.compare(password, hash);
6879
+ return bcrypt.verify(password, hash2);
6867
6880
  }
6868
6881
  var passwordValidator = createPasswordParser({
6869
6882
  minLength: 8,
@@ -6902,21 +6915,37 @@ function validatePasswordStrength(password) {
6902
6915
  import jwt from "jsonwebtoken";
6903
6916
  import crypto2 from "crypto";
6904
6917
  import { env as env2 } from "@spfn/auth/config";
6918
+ var PUBLIC_KEY_CACHE_MAX = 1e3;
6919
+ var publicKeyCache = /* @__PURE__ */ new Map();
6920
+ function getPublicKeyObject(publicKeyB64) {
6921
+ const cached = publicKeyCache.get(publicKeyB64);
6922
+ if (cached) {
6923
+ return cached;
6924
+ }
6925
+ const keyObject = crypto2.createPublicKey({
6926
+ key: Buffer.from(publicKeyB64, "base64"),
6927
+ format: "der",
6928
+ type: "spki"
6929
+ });
6930
+ if (publicKeyCache.size >= PUBLIC_KEY_CACHE_MAX) {
6931
+ const oldest = publicKeyCache.keys().next().value;
6932
+ if (oldest !== void 0) {
6933
+ publicKeyCache.delete(oldest);
6934
+ }
6935
+ }
6936
+ publicKeyCache.set(publicKeyB64, keyObject);
6937
+ return keyObject;
6938
+ }
6905
6939
  function generateToken(payload) {
6906
6940
  return jwt.sign(payload, env2.SPFN_AUTH_JWT_SECRET, {
6907
6941
  expiresIn: env2.SPFN_AUTH_JWT_EXPIRES_IN
6908
6942
  });
6909
6943
  }
6910
6944
  function verifyToken(token) {
6911
- return jwt.verify(token, env2.SPFN_AUTH_JWT_SECRET);
6945
+ return jwt.verify(token, env2.SPFN_AUTH_JWT_SECRET, { algorithms: ["HS256"] });
6912
6946
  }
6913
6947
  function verifyClientToken(token, publicKeyB64, algorithm) {
6914
- const publicKeyDER = Buffer.from(publicKeyB64, "base64");
6915
- const publicKeyObject = crypto2.createPublicKey({
6916
- key: publicKeyDER,
6917
- format: "der",
6918
- type: "spki"
6919
- });
6948
+ const publicKeyObject = getPublicKeyObject(publicKeyB64);
6920
6949
  let decoded;
6921
6950
  try {
6922
6951
  decoded = jwt.verify(token, publicKeyObject, {
@@ -7002,6 +7031,7 @@ import {
7002
7031
  } from "@spfn/auth/errors";
7003
7032
 
7004
7033
  // src/server/services/verification.service.ts
7034
+ import crypto4 from "crypto";
7005
7035
  import { env as env4 } from "@spfn/auth/config";
7006
7036
  import { InvalidVerificationCodeError } from "@spfn/auth/errors";
7007
7037
  import jwt2 from "jsonwebtoken";
@@ -7027,11 +7057,12 @@ var authLogger = {
7027
7057
 
7028
7058
  // src/server/services/verification.service.ts
7029
7059
  init_repositories();
7060
+ var ACCOUNT_EXISTS_NOTICE_DEDUPE_MINUTES = 60;
7030
7061
  var VERIFICATION_TOKEN_EXPIRY = "15m";
7031
7062
  var VERIFICATION_CODE_EXPIRY_MINUTES = 5;
7032
7063
  var MAX_VERIFICATION_ATTEMPTS = 5;
7033
7064
  function generateVerificationCode() {
7034
- return Math.floor(Math.random() * 1e6).toString().padStart(6, "0");
7065
+ return crypto4.randomInt(0, 1e6).toString().padStart(6, "0");
7035
7066
  }
7036
7067
  async function storeVerificationCode(target, targetType, code, purpose) {
7037
7068
  const expiresAt = /* @__PURE__ */ new Date();
@@ -7126,8 +7157,39 @@ async function sendVerificationSMS(phone, code, purpose) {
7126
7157
  });
7127
7158
  }
7128
7159
  }
7160
+ async function accountExistsForTarget(target, targetType) {
7161
+ const user = targetType === "email" ? await usersRepository.findByEmail(target) : await usersRepository.findByPhone(target);
7162
+ return !!user;
7163
+ }
7164
+ async function sendAccountExistsNotice(target, targetType) {
7165
+ const result = targetType === "email" ? await sendEmail({ to: target, template: "account-exists", data: {} }) : await sendSMS({ to: target, template: "account-exists", data: {} });
7166
+ if (!result.success) {
7167
+ const log = targetType === "email" ? authLogger.email : authLogger.sms;
7168
+ log.error("Failed to send account-exists notice", { target, error: result.error });
7169
+ }
7170
+ }
7129
7171
  async function sendVerificationCodeService(params) {
7130
7172
  const { target, targetType, purpose } = params;
7173
+ if (purpose === "registration" && await accountExistsForTarget(target, targetType)) {
7174
+ const recentNotice = await verificationCodesRepository.findValidByTargetAndPurpose(target, purpose);
7175
+ if (!recentNotice) {
7176
+ const dedupeExpiresAt = new Date(Date.now() + ACCOUNT_EXISTS_NOTICE_DEDUPE_MINUTES * 6e4);
7177
+ await verificationCodesRepository.invalidatePreviousCodes(target, purpose);
7178
+ await verificationCodesRepository.create({
7179
+ target,
7180
+ targetType,
7181
+ code: generateVerificationCode(),
7182
+ purpose,
7183
+ expiresAt: dedupeExpiresAt,
7184
+ attempts: 0
7185
+ });
7186
+ await sendAccountExistsNotice(target, targetType);
7187
+ }
7188
+ return {
7189
+ success: true,
7190
+ expiresAt: new Date(Date.now() + VERIFICATION_CODE_EXPIRY_MINUTES * 6e4).toISOString()
7191
+ };
7192
+ }
7131
7193
  const code = generateVerificationCode();
7132
7194
  const codeRecord = await storeVerificationCode(target, targetType, code, purpose);
7133
7195
  if (targetType === "email") {
@@ -7344,27 +7406,12 @@ var invitationAcceptedEvent = defineEvent(
7344
7406
  );
7345
7407
 
7346
7408
  // src/server/services/auth.service.ts
7347
- async function checkAccountExistsService(params) {
7348
- const { email, phone } = params;
7349
- let identifier;
7350
- let identifierType;
7351
- let user;
7352
- if (email) {
7353
- identifier = email;
7354
- identifierType = "email";
7355
- user = await usersRepository.findByEmail(email);
7356
- } else if (phone) {
7357
- identifier = phone;
7358
- identifierType = "phone";
7359
- user = await usersRepository.findByPhone(phone);
7360
- } else {
7361
- throw new ValidationError2({ message: "Either email or phone must be provided" });
7409
+ var dummyHashPromise = null;
7410
+ function getDummyHash() {
7411
+ if (!dummyHashPromise) {
7412
+ dummyHashPromise = hashPassword("spfn-nonexistent-account-timing-equalizer");
7362
7413
  }
7363
- return {
7364
- exists: !!user,
7365
- identifier,
7366
- identifierType
7367
- };
7414
+ return dummyHashPromise;
7368
7415
  }
7369
7416
  async function registerService(params) {
7370
7417
  const { email, phone, verificationToken, password, publicKey, keyId, fingerprint, algorithm, metadata } = params;
@@ -7431,6 +7478,7 @@ async function loginService(params) {
7431
7478
  throw new ValidationError2({ message: "Either email or phone must be provided" });
7432
7479
  }
7433
7480
  if (!user || !user.passwordHash) {
7481
+ await verifyPassword(password, await getDummyHash());
7434
7482
  throw new InvalidCredentialsError();
7435
7483
  }
7436
7484
  const isValid = await verifyPassword(password, user.passwordHash);
@@ -7717,6 +7765,7 @@ async function syncMappings(allMappings, rolesByName, permsByName) {
7717
7765
 
7718
7766
  // src/server/services/permission.service.ts
7719
7767
  init_repositories();
7768
+ import { ForbiddenError } from "@spfn/core/errors";
7720
7769
  async function getUserPermissions(userId) {
7721
7770
  const userIdNum = typeof userId === "string" ? Number(userId) : Number(userId);
7722
7771
  const user = await usersRepository.findById(userIdNum);
@@ -7781,15 +7830,32 @@ async function hasAnyRole(userId, roleNames) {
7781
7830
  }
7782
7831
  return false;
7783
7832
  }
7833
+ async function assertCanAssignRole(callerUserId, targetRoleId) {
7834
+ const callerRole = await getUserRole(callerUserId);
7835
+ if (callerRole === "superadmin") {
7836
+ return;
7837
+ }
7838
+ const targetRole = await rolesRepository.findById(targetRoleId);
7839
+ if (targetRole?.name === "superadmin") {
7840
+ throw new ForbiddenError({ message: "Only superadmin can assign superadmin role" });
7841
+ }
7842
+ if (targetRole?.name === "admin") {
7843
+ const canPromote = await hasPermission(callerUserId, "admin:promote");
7844
+ if (!canPromote) {
7845
+ throw new ForbiddenError({ message: "admin:promote permission required to assign admin role" });
7846
+ }
7847
+ }
7848
+ }
7784
7849
 
7785
7850
  // src/server/services/index.ts
7786
7851
  init_role_service();
7787
7852
 
7788
7853
  // src/server/services/invitation.service.ts
7789
7854
  init_repositories();
7790
- import crypto4 from "crypto";
7855
+ import crypto5 from "crypto";
7856
+ import { BadRequestError, NotFoundError as NotFoundError2, ConflictError } from "@spfn/core/errors";
7791
7857
  function generateInvitationToken() {
7792
- return crypto4.randomUUID();
7858
+ return crypto5.randomUUID();
7793
7859
  }
7794
7860
  function calculateExpiresAt(days = 7) {
7795
7861
  const expiresAt = /* @__PURE__ */ new Date();
@@ -7800,23 +7866,23 @@ async function createInvitation(params) {
7800
7866
  const { email, roleId, invitedBy, expiresInDays = 7, expiresAt: expiresAtParam, metadata } = params;
7801
7867
  const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
7802
7868
  if (!emailRegex.test(email)) {
7803
- throw new Error("Invalid email format");
7869
+ throw new BadRequestError({ message: "Invalid email format" });
7804
7870
  }
7805
7871
  const existingUser = await usersRepository.findByEmail(email);
7806
7872
  if (existingUser) {
7807
- throw new Error("User with this email already exists");
7873
+ throw new ConflictError({ message: "User with this email already exists" });
7808
7874
  }
7809
7875
  const existingInvitation = await invitationsRepository.findPendingByEmail(email);
7810
7876
  if (existingInvitation) {
7811
- throw new Error("Pending invitation already exists for this email");
7877
+ throw new ConflictError({ message: "Pending invitation already exists for this email" });
7812
7878
  }
7813
7879
  const role = await rolesRepository.findById(roleId);
7814
7880
  if (!role) {
7815
- throw new Error(`Role with id ${roleId} not found`);
7881
+ throw new NotFoundError2({ message: `Role with id ${roleId} not found`, resource: "Role" });
7816
7882
  }
7817
7883
  const inviter = await usersRepository.findById(invitedBy);
7818
7884
  if (!inviter) {
7819
- throw new Error(`User with id ${invitedBy} not found`);
7885
+ throw new NotFoundError2({ message: `User with id ${invitedBy} not found`, resource: "User" });
7820
7886
  }
7821
7887
  const token = generateInvitationToken();
7822
7888
  const expiresAt = expiresAtParam ?? calculateExpiresAt(expiresInDays);
@@ -7870,12 +7936,12 @@ async function acceptInvitation(params) {
7870
7936
  const { token, password, publicKey, keyId, fingerprint, algorithm } = params;
7871
7937
  const validation = await validateInvitation(token);
7872
7938
  if (!validation.valid || !validation.invitation) {
7873
- throw new Error(validation.error || "Invalid invitation");
7939
+ throw new BadRequestError({ message: validation.error || "Invalid invitation" });
7874
7940
  }
7875
7941
  const invitation = validation.invitation;
7876
7942
  const role = await rolesRepository.findById(invitation.roleId);
7877
7943
  if (!role) {
7878
- throw new Error("Role not found");
7944
+ throw new NotFoundError2({ message: "Role not found", resource: "Role" });
7879
7945
  }
7880
7946
  const passwordHash = await hashPassword(password);
7881
7947
  const newUser = await usersRepository.create({
@@ -7922,10 +7988,10 @@ async function listInvitations(params) {
7922
7988
  async function cancelInvitation(id11, cancelledBy, reason) {
7923
7989
  const invitation = await invitationsRepository.findById(id11);
7924
7990
  if (!invitation) {
7925
- throw new Error("Invitation not found");
7991
+ throw new NotFoundError2({ message: "Invitation not found", resource: "Invitation" });
7926
7992
  }
7927
7993
  if (invitation.status !== "pending") {
7928
- throw new Error(`Cannot cancel ${invitation.status} invitation`);
7994
+ throw new ConflictError({ message: `Cannot cancel ${invitation.status} invitation` });
7929
7995
  }
7930
7996
  await invitationsRepository.cancel(id11, cancelledBy, reason, invitation.metadata);
7931
7997
  console.log(`[Auth] \u26A0\uFE0F Invitation cancelled: ${invitation.email} (reason: ${reason || "none"})`);
@@ -7944,10 +8010,10 @@ async function expireOldInvitations() {
7944
8010
  async function resendInvitation(id11, expiresInDays = 7) {
7945
8011
  const invitation = await invitationsRepository.findById(id11);
7946
8012
  if (!invitation) {
7947
- throw new Error("Invitation not found");
8013
+ throw new NotFoundError2({ message: "Invitation not found", resource: "Invitation" });
7948
8014
  }
7949
8015
  if (!["pending", "expired"].includes(invitation.status)) {
7950
- throw new Error(`Cannot resend ${invitation.status} invitation`);
8016
+ throw new ConflictError({ message: `Cannot resend ${invitation.status} invitation` });
7951
8017
  }
7952
8018
  const newExpiresAt = calculateExpiresAt(expiresInDays);
7953
8019
  const updated = await invitationsRepository.resend(id11, newExpiresAt);
@@ -8668,16 +8734,8 @@ async function persistNativeLogin(identity, params) {
8668
8734
  // src/server/routes/auth/index.ts
8669
8735
  init_esm();
8670
8736
  import { Transactional } from "@spfn/core/db";
8737
+ import { rateLimit } from "@spfn/core/middleware";
8671
8738
  import { defineRouter, route } from "@spfn/core/route";
8672
- var checkAccountExists = route.post("/_auth/exists").input({
8673
- body: Type.Union([
8674
- Type.Object({ email: EmailSchema }),
8675
- Type.Object({ phone: PhoneSchema })
8676
- ])
8677
- }).skip(["auth"]).handler(async (c) => {
8678
- const { body } = await c.data();
8679
- return await checkAccountExistsService(body);
8680
- });
8681
8739
  var sendVerificationCode = route.post("/_auth/codes").input({
8682
8740
  body: Type.Object({
8683
8741
  target: Type.String({
@@ -8686,7 +8744,7 @@ var sendVerificationCode = route.post("/_auth/codes").input({
8686
8744
  targetType: TargetTypeSchema,
8687
8745
  purpose: VerificationPurposeSchema
8688
8746
  })
8689
- }).skip(["auth"]).handler(async (c) => {
8747
+ }).use([rateLimit({ limit: 5, windowMs: 6e4 })]).skip(["auth"]).handler(async (c) => {
8690
8748
  const { body } = await c.data();
8691
8749
  return await sendVerificationCodeService(body);
8692
8750
  });
@@ -8704,7 +8762,7 @@ var verifyCode = route.post("/_auth/codes/verify").input({
8704
8762
  }),
8705
8763
  purpose: VerificationPurposeSchema
8706
8764
  })
8707
- }).skip(["auth"]).handler(async (c) => {
8765
+ }).use([rateLimit({ limit: 10, windowMs: 6e4 })]).skip(["auth"]).handler(async (c) => {
8708
8766
  const { body } = await c.data();
8709
8767
  return await verifyCodeService(body);
8710
8768
  });
@@ -8756,7 +8814,7 @@ var login = route.post("/_auth/login").input({
8756
8814
  algorithm: Type.Union(KEY_ALGORITHM.map((algo) => Type.Literal(algo)), { description: "Signature algorithm" }),
8757
8815
  oldKeyId: Type.Optional(Type.String({ description: "Previous key ID for rotation" }))
8758
8816
  })
8759
- }).use([Transactional()]).skip(["auth"]).handler(async (c) => {
8817
+ }).use([rateLimit({ limit: 10, windowMs: 6e4 }), Transactional()]).skip(["auth"]).handler(async (c) => {
8760
8818
  const { body } = await c.data();
8761
8819
  return await loginService(body);
8762
8820
  });
@@ -8816,7 +8874,6 @@ var issueOneTimeToken = route.post("/_auth/tokens").handler(async (c) => {
8816
8874
  return await issueOneTimeTokenService(userId);
8817
8875
  });
8818
8876
  var authRouter = defineRouter({
8819
- checkAccountExists,
8820
8877
  sendVerificationCode,
8821
8878
  verifyCode,
8822
8879
  register,
@@ -8965,7 +9022,7 @@ var optionalAuth = defineMiddleware("optionalAuth", async (c, next) => {
8965
9022
 
8966
9023
  // src/server/middleware/require-permission.ts
8967
9024
  import { defineMiddleware as defineMiddleware2 } from "@spfn/core/route";
8968
- import { ForbiddenError } from "@spfn/core/errors";
9025
+ import { ForbiddenError as ForbiddenError2 } from "@spfn/core/errors";
8969
9026
  import { InsufficientPermissionsError } from "@spfn/auth/errors";
8970
9027
  import { getAuth as getAuth2, hasAllPermissions as hasAllPermissions2, hasAnyPermission as hasAnyPermission2, authLogger as authLogger3 } from "@spfn/auth/server";
8971
9028
  var requirePermissions = defineMiddleware2(
@@ -8977,7 +9034,7 @@ var requirePermissions = defineMiddleware2(
8977
9034
  permissions: permissionNames,
8978
9035
  path: c.req.path
8979
9036
  });
8980
- throw new ForbiddenError({ message: "Authentication required" });
9037
+ throw new ForbiddenError2({ message: "Authentication required" });
8981
9038
  }
8982
9039
  const { userId } = auth;
8983
9040
  const allowed = await hasAllPermissions2(userId, permissionNames);
@@ -9005,7 +9062,7 @@ var requireAnyPermission = defineMiddleware2(
9005
9062
  permissions: permissionNames,
9006
9063
  path: c.req.path
9007
9064
  });
9008
- throw new ForbiddenError({ message: "Authentication required" });
9065
+ throw new ForbiddenError2({ message: "Authentication required" });
9009
9066
  }
9010
9067
  const { userId } = auth;
9011
9068
  const allowed = await hasAnyPermission2(userId, permissionNames);
@@ -9028,7 +9085,7 @@ var requireAnyPermission = defineMiddleware2(
9028
9085
  // src/server/middleware/require-role.ts
9029
9086
  import { defineMiddleware as defineMiddleware3 } from "@spfn/core/route";
9030
9087
  import { getAuth as getAuth3, authLogger as authLogger4 } from "@spfn/auth/server";
9031
- import { ForbiddenError as ForbiddenError2 } from "@spfn/core/errors";
9088
+ import { ForbiddenError as ForbiddenError3 } from "@spfn/core/errors";
9032
9089
  import { InsufficientRoleError } from "@spfn/auth/errors";
9033
9090
  var requireRole = defineMiddleware3(
9034
9091
  "role",
@@ -9039,7 +9096,7 @@ var requireRole = defineMiddleware3(
9039
9096
  roles: roleNames,
9040
9097
  path: c.req.path
9041
9098
  });
9042
- throw new ForbiddenError2({ message: "Authentication required" });
9099
+ throw new ForbiddenError3({ message: "Authentication required" });
9043
9100
  }
9044
9101
  const { userId, role: userRole } = auth;
9045
9102
  if (!userRole || !roleNames.includes(userRole)) {
@@ -9063,7 +9120,7 @@ var requireRole = defineMiddleware3(
9063
9120
  // src/server/middleware/role-guard.ts
9064
9121
  import { defineMiddleware as defineMiddleware4 } from "@spfn/core/route";
9065
9122
  import { getAuth as getAuth4, authLogger as authLogger5 } from "@spfn/auth/server";
9066
- import { ForbiddenError as ForbiddenError3 } from "@spfn/core/errors";
9123
+ import { ForbiddenError as ForbiddenError4 } from "@spfn/core/errors";
9067
9124
  import { InsufficientRoleError as InsufficientRoleError2 } from "@spfn/auth/errors";
9068
9125
  var roleGuard = defineMiddleware4(
9069
9126
  "roleGuard",
@@ -9077,7 +9134,7 @@ var roleGuard = defineMiddleware4(
9077
9134
  authLogger5.middleware.warn("Role guard failed: not authenticated", {
9078
9135
  path: c.req.path
9079
9136
  });
9080
- throw new ForbiddenError3({ message: "Authentication required" });
9137
+ throw new ForbiddenError4({ message: "Authentication required" });
9081
9138
  }
9082
9139
  const { userId, role: userRole } = auth;
9083
9140
  if (deny && deny.length > 0) {
@@ -9240,6 +9297,7 @@ var createInvitation2 = route2.post("/_auth/invitations").input({
9240
9297
  }).use([authenticate, requirePermissions("user:invite")]).handler(async (c) => {
9241
9298
  const { body } = await c.data();
9242
9299
  const { userId } = getAuth(c);
9300
+ await assertCanAssignRole(userId, body.roleId);
9243
9301
  const invitation = await createInvitation({
9244
9302
  email: body.email,
9245
9303
  roleId: body.roleId,
@@ -9422,6 +9480,7 @@ init_esm();
9422
9480
  init_types();
9423
9481
  import { Transactional as Transactional3 } from "@spfn/core/db";
9424
9482
  import { ValidationError as ValidationError7 } from "@spfn/core/errors";
9483
+ import { rateLimit as rateLimit2 } from "@spfn/core/middleware";
9425
9484
  import { defineRouter as defineRouter4, route as route4 } from "@spfn/core/route";
9426
9485
  var providerParams = Type.Object({
9427
9486
  provider: Type.Union(SOCIAL_PROVIDERS.map((p) => Type.Literal(p)), {
@@ -9434,7 +9493,7 @@ var oauthGoogleStart = route4.get("/_auth/oauth/google").input({
9434
9493
  description: "Encrypted OAuth state (returnUrl, publicKey, keyId, fingerprint, algorithm)"
9435
9494
  })
9436
9495
  })
9437
- }).skip(["auth"]).handler(async (c) => {
9496
+ }).use([rateLimit2({ limit: 20, windowMs: 6e4 })]).skip(["auth"]).handler(async (c) => {
9438
9497
  const { query } = await c.data();
9439
9498
  if (!isGoogleOAuthEnabled()) {
9440
9499
  return c.redirect(buildOAuthErrorUrl("Google OAuth is not configured"));
@@ -9502,7 +9561,7 @@ var oauthStart = route4.post("/_auth/oauth/start").input({
9502
9561
  description: "Custom metadata passed to authRegisterEvent (e.g. referral code, UTM params)"
9503
9562
  }))
9504
9563
  })
9505
- }).skip(["auth"]).handler(async (c) => {
9564
+ }).use([rateLimit2({ limit: 20, windowMs: 6e4 })]).skip(["auth"]).handler(async (c) => {
9506
9565
  const { body } = await c.data();
9507
9566
  const result = await oauthStartService(body);
9508
9567
  return result;
@@ -9555,7 +9614,7 @@ var oauthProviderStart = route4.get("/_auth/oauth/:provider").input({
9555
9614
  description: "Encrypted OAuth state (returnUrl, publicKey, keyId, fingerprint, algorithm)"
9556
9615
  })
9557
9616
  })
9558
- }).skip(["auth"]).handler(async (c) => {
9617
+ }).use([rateLimit2({ limit: 20, windowMs: 6e4 })]).skip(["auth"]).handler(async (c) => {
9559
9618
  const { params, query } = await c.data();
9560
9619
  const provider = getOAuthProvider(params.provider);
9561
9620
  if (!provider?.isEnabled()) {
@@ -9658,9 +9717,8 @@ var oauthRouter = defineRouter4({
9658
9717
  });
9659
9718
 
9660
9719
  // src/server/routes/admin/index.ts
9661
- init_repositories();
9662
9720
  init_esm();
9663
- import { ForbiddenError as ForbiddenError4 } from "@spfn/core/errors";
9721
+ import { ForbiddenError as ForbiddenError5 } from "@spfn/core/errors";
9664
9722
  import { route as route5 } from "@spfn/core/route";
9665
9723
  var listRoles = route5.get("/_auth/admin/roles").input({
9666
9724
  query: Type.Object({
@@ -9729,26 +9787,14 @@ var updateUserRole = route5.patch("/_auth/admin/users/:userId/role").input({
9729
9787
  }).use([authenticate, requireRole("admin", "superadmin")]).handler(async (c) => {
9730
9788
  const { params, body } = await c.data();
9731
9789
  const auth = getAuth(c);
9732
- const callerRole = await getUserRole(auth.userId);
9733
9790
  if (params.userId === Number(auth.userId)) {
9734
- throw new ForbiddenError4({ message: "Cannot change your own role" });
9791
+ throw new ForbiddenError5({ message: "Cannot change your own role" });
9735
9792
  }
9736
9793
  const targetRole = await getUserRole(params.userId);
9737
9794
  if (targetRole === "superadmin") {
9738
- throw new ForbiddenError4({ message: "Cannot modify superadmin role" });
9739
- }
9740
- if (callerRole !== "superadmin") {
9741
- const newRole = await rolesRepository.findById(body.roleId);
9742
- if (newRole?.name === "superadmin") {
9743
- throw new ForbiddenError4({ message: "Only superadmin can assign superadmin role" });
9744
- }
9745
- if (newRole?.name === "admin") {
9746
- const canPromote = await hasPermission(auth.userId, "admin:promote");
9747
- if (!canPromote) {
9748
- throw new ForbiddenError4({ message: "admin:promote permission required to assign admin role" });
9749
- }
9750
- }
9795
+ throw new ForbiddenError5({ message: "Cannot modify superadmin role" });
9751
9796
  }
9797
+ await assertCanAssignRole(auth.userId, body.roleId);
9752
9798
  await updateUserService(params.userId, { roleId: body.roleId });
9753
9799
  return { userId: params.userId, roleId: body.roleId };
9754
9800
  });
@@ -9756,7 +9802,6 @@ var updateUserRole = route5.patch("/_auth/admin/users/:userId/role").input({
9756
9802
  // src/server/routes/index.ts
9757
9803
  var mainAuthRouter = defineRouter5({
9758
9804
  // Auth routes
9759
- checkAccountExists,
9760
9805
  sendVerificationCode,
9761
9806
  verifyCode,
9762
9807
  register,
@@ -9807,11 +9852,11 @@ init_types();
9807
9852
  init_schema3();
9808
9853
 
9809
9854
  // src/server/lib/crypto.ts
9810
- import crypto5 from "crypto";
9855
+ import crypto6 from "crypto";
9811
9856
  import jwt3 from "jsonwebtoken";
9812
9857
  function generateKeyPairES256() {
9813
- const keyId = crypto5.randomUUID();
9814
- const { privateKey, publicKey } = crypto5.generateKeyPairSync("ec", {
9858
+ const keyId = crypto6.randomUUID();
9859
+ const { privateKey, publicKey } = crypto6.generateKeyPairSync("ec", {
9815
9860
  namedCurve: "P-256",
9816
9861
  // ES256
9817
9862
  publicKeyEncoding: {
@@ -9825,7 +9870,7 @@ function generateKeyPairES256() {
9825
9870
  });
9826
9871
  const privateKeyB64 = privateKey.toString("base64");
9827
9872
  const publicKeyB64 = publicKey.toString("base64");
9828
- const fingerprint = crypto5.createHash("sha256").update(publicKey).digest("hex");
9873
+ const fingerprint = crypto6.createHash("sha256").update(publicKey).digest("hex");
9829
9874
  return {
9830
9875
  privateKey: privateKeyB64,
9831
9876
  publicKey: publicKeyB64,
@@ -9835,8 +9880,8 @@ function generateKeyPairES256() {
9835
9880
  };
9836
9881
  }
9837
9882
  function generateKeyPairRS256() {
9838
- const keyId = crypto5.randomUUID();
9839
- const { privateKey, publicKey } = crypto5.generateKeyPairSync("rsa", {
9883
+ const keyId = crypto6.randomUUID();
9884
+ const { privateKey, publicKey } = crypto6.generateKeyPairSync("rsa", {
9840
9885
  modulusLength: 2048,
9841
9886
  publicKeyEncoding: {
9842
9887
  type: "spki",
@@ -9849,7 +9894,7 @@ function generateKeyPairRS256() {
9849
9894
  });
9850
9895
  const privateKeyB64 = privateKey.toString("base64");
9851
9896
  const publicKeyB64 = publicKey.toString("base64");
9852
- const fingerprint = crypto5.createHash("sha256").update(publicKey).digest("hex");
9897
+ const fingerprint = crypto6.createHash("sha256").update(publicKey).digest("hex");
9853
9898
  return {
9854
9899
  privateKey: privateKeyB64,
9855
9900
  publicKey: publicKeyB64,
@@ -9864,7 +9909,7 @@ function generateKeyPair(algorithm = "ES256") {
9864
9909
  function generateClientToken(payload, privateKeyB64, algorithm, options) {
9865
9910
  try {
9866
9911
  const privateKeyDER = Buffer.from(privateKeyB64, "base64");
9867
- const privateKeyObject = crypto5.createPrivateKey({
9912
+ const privateKeyObject = crypto6.createPrivateKey({
9868
9913
  key: privateKeyDER,
9869
9914
  format: "der",
9870
9915
  type: "pkcs8"
@@ -9919,8 +9964,8 @@ async function getSessionSecretKey() {
9919
9964
  }
9920
9965
  async function getSecretFingerprint() {
9921
9966
  const key = await getSessionSecretKey();
9922
- const hash = await crypto.subtle.digest("SHA-256", key.buffer);
9923
- const hex = Array.from(new Uint8Array(hash)).map((b) => b.toString(16).padStart(2, "0")).join("");
9967
+ const hash2 = await crypto.subtle.digest("SHA-256", key.buffer);
9968
+ const hex = Array.from(new Uint8Array(hash2)).map((b) => b.toString(16).padStart(2, "0")).join("");
9924
9969
  return hex.slice(0, 8);
9925
9970
  }
9926
9971
  async function sealSession(data, ttl = 60 * 60 * 24 * 7) {
@@ -10161,6 +10206,7 @@ export {
10161
10206
  acceptInvitation,
10162
10207
  addPermissionToRole,
10163
10208
  appleProvider,
10209
+ assertCanAssignRole,
10164
10210
  authLogger,
10165
10211
  authLoginEvent,
10166
10212
  authMetadata,
@@ -10172,7 +10218,6 @@ export {
10172
10218
  buildOAuthErrorUrl,
10173
10219
  cancelInvitation,
10174
10220
  changePasswordService,
10175
- checkAccountExistsService,
10176
10221
  checkUsernameAvailableService,
10177
10222
  configureAuth,
10178
10223
  createAuthLifecycle,