@spfn/auth 0.2.0-beta.87 → 0.2.0-beta.89
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/README.md +384 -18
- package/dist/{authenticate-C7w-pLOR.d.ts → authenticate-DK-hLSOW.d.ts} +154 -5
- package/dist/client-proof.d.ts +122 -2
- package/dist/client-proof.js +366 -5
- package/dist/client-proof.js.map +1 -1
- package/dist/config.d.ts +36 -0
- package/dist/config.js +18 -0
- package/dist/config.js.map +1 -1
- package/dist/errors.d.ts +93 -2
- package/dist/errors.js +57 -0
- package/dist/errors.js.map +1 -1
- package/dist/index.d.ts +32 -3
- package/dist/index.js +60 -0
- package/dist/index.js.map +1 -1
- package/dist/nextjs/server.d.ts +2 -2
- package/dist/server.d.ts +126 -11
- package/dist/server.js +527 -106
- package/dist/server.js.map +1 -1
- package/dist/{session-CGxgH3C9.d.ts → session-CFK4BT25.d.ts} +1 -1
- package/dist/{types-1BMx0OX1.d.ts → types-CD95yudz.d.ts} +15 -1
- package/migrations/20251125021229_premium_famine/snapshot.json +2641 -0
- package/migrations/20260225130050_smooth_the_fury/snapshot.json +2686 -0
- package/migrations/20260308141417_deep_iceman/snapshot.json +2686 -0
- package/migrations/20260308151309_perfect_deathbird/snapshot.json +2731 -0
- package/migrations/20260308201135_concerned_rawhide_kid/snapshot.json +2786 -0
- package/migrations/20260629103209_lethal_lifeguard/snapshot.json +2786 -0
- package/migrations/20260709073531_easy_hardball/snapshot.json +3119 -0
- package/migrations/20260714081434_glossy_major_mapleleaf/snapshot.json +3112 -0
- package/migrations/20260804105939_amazing_bushwacker/migration.sql +3 -0
- package/migrations/20260804105939_amazing_bushwacker/snapshot.json +3112 -0
- package/migrations/20260804110033_fat_piledriver/migration.sql +2 -0
- package/migrations/20260804110033_fat_piledriver/snapshot.json +3138 -0
- package/package.json +3 -6
- package/migrations/meta/0000_snapshot.json +0 -1632
- package/migrations/meta/0001_snapshot.json +0 -1660
- package/migrations/meta/0002_snapshot.json +0 -1660
- package/migrations/meta/0003_snapshot.json +0 -1689
- package/migrations/meta/0004_snapshot.json +0 -1721
- package/migrations/meta/0005_snapshot.json +0 -1721
- package/migrations/meta/0006_snapshot.json +0 -1921
- package/migrations/meta/0007_snapshot.json +0 -1916
- package/migrations/meta/_journal.json +0 -62
- /package/migrations/{0000_premium_famine.sql → 20251125021229_premium_famine/migration.sql} +0 -0
- /package/migrations/{0001_smooth_the_fury.sql → 20260225130050_smooth_the_fury/migration.sql} +0 -0
- /package/migrations/{0002_deep_iceman.sql → 20260308141417_deep_iceman/migration.sql} +0 -0
- /package/migrations/{0003_perfect_deathbird.sql → 20260308151309_perfect_deathbird/migration.sql} +0 -0
- /package/migrations/{0004_concerned_rawhide_kid.sql → 20260308201135_concerned_rawhide_kid/migration.sql} +0 -0
- /package/migrations/{0005_lethal_lifeguard.sql → 20260629103209_lethal_lifeguard/migration.sql} +0 -0
- /package/migrations/{0006_easy_hardball.sql → 20260709073531_easy_hardball/migration.sql} +0 -0
- /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(
|
|
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.
|
|
8289
|
+
const { userId, keyId, publicKey, fingerprint, algorithm = "ES256", deviceName, platform } = params;
|
|
8290
|
+
const existing = await keysRepository.findByKeyId(keyId);
|
|
8186
8291
|
if (existing) {
|
|
8187
|
-
|
|
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
|
+
createdAtMillis: row.createdAt.getTime(),
|
|
8360
|
+
lastUsedAtMillis: row.lastUsedAt?.getTime(),
|
|
8361
|
+
expiresAtMillis: row.expiresAt?.getTime(),
|
|
8362
|
+
isExpired: isExpired(row.expiresAt),
|
|
8363
|
+
isActive: row.isActive,
|
|
8364
|
+
revokedAtMillis: row.revokedAt?.getTime()
|
|
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() +
|
|
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
|
|
9414
|
-
import {
|
|
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 {
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
9952
|
-
|
|
9953
|
-
|
|
9954
|
-
|
|
9955
|
-
|
|
9956
|
-
|
|
9957
|
-
|
|
9958
|
-
|
|
9959
|
-
|
|
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
|
|
9962
|
-
|
|
9963
|
-
|
|
9964
|
-
|
|
9965
|
-
|
|
9966
|
-
|
|
9967
|
-
|
|
9968
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
10101
|
-
|
|
10102
|
-
|
|
10103
|
-
|
|
10104
|
-
|
|
10105
|
-
|
|
10106
|
-
|
|
10107
|
-
|
|
10108
|
-
|
|
10109
|
-
|
|
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
|
-
|
|
10112
|
-
|
|
10113
|
-
|
|
10114
|
-
|
|
10115
|
-
|
|
10116
|
-
|
|
10117
|
-
|
|
10118
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
10516
|
+
throw new ValidationError8({
|
|
10217
10517
|
message: "OAuth state validation failed"
|
|
10218
10518
|
});
|
|
10219
10519
|
}
|
|
10220
10520
|
if (stateData.provider !== provider) {
|
|
10221
|
-
throw new
|
|
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
|
|
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
|
|
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
|
|
10698
|
+
throw new ValidationError8({
|
|
10401
10699
|
message: "No Google account linked. User must sign in with Google first."
|
|
10402
10700
|
});
|
|
10403
10701
|
}
|
|
10404
|
-
const
|
|
10405
|
-
if (!
|
|
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
|
|
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
|
|
10750
|
+
throw new NativeSignInUnsupportedError5({
|
|
10449
10751
|
message: `Provider '${params.provider}' does not support native id_token sign-in.`
|
|
10450
10752
|
});
|
|
10451
10753
|
}
|
|
10452
|
-
|
|
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
|
|
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
|
|
11530
|
+
return createHash5("sha256").update(bytes).digest("hex");
|
|
11144
11531
|
}
|
|
11145
11532
|
|
|
11146
11533
|
// src/server/client-proof/refusal.ts
|
|
@@ -11206,6 +11593,18 @@ var ClientProofRefusal = class _ClientProofRefusal {
|
|
|
11206
11593
|
static unprocessable() {
|
|
11207
11594
|
return contractViolation("the request could not be processed");
|
|
11208
11595
|
}
|
|
11596
|
+
/**
|
|
11597
|
+
* A client that ships separately from the server said nothing about which
|
|
11598
|
+
* contract it was built against. Without it the server cannot tell whether
|
|
11599
|
+
* the two ends agree, and answering as though they do is what produces the
|
|
11600
|
+
* undecodable body this check exists to replace.
|
|
11601
|
+
*/
|
|
11602
|
+
static contractVersionMissing() {
|
|
11603
|
+
return contractViolation("a client of this kind must state the contract version it was generated from");
|
|
11604
|
+
}
|
|
11605
|
+
static contractVersionUnsupported() {
|
|
11606
|
+
return contractViolation("the stated contract version is outside the range this server serves");
|
|
11607
|
+
}
|
|
11209
11608
|
// ---- the profile allowlist ----------------------------------------------
|
|
11210
11609
|
static profileRejected() {
|
|
11211
11610
|
return new _ClientProofRefusal("PROFILE_REJECTED", "the named auth profile is not on this contract's allowlist");
|
|
@@ -12275,8 +12674,9 @@ var deleteCookie = (c, name, opt) => {
|
|
|
12275
12674
|
|
|
12276
12675
|
// src/server/routes/oauth/index.ts
|
|
12277
12676
|
init_types();
|
|
12677
|
+
init_schema3();
|
|
12278
12678
|
import { Transactional as Transactional3 } from "@spfn/core/db";
|
|
12279
|
-
import { ValidationError as
|
|
12679
|
+
import { ValidationError as ValidationError9 } from "@spfn/core/errors";
|
|
12280
12680
|
import { rateLimitPolicy as rateLimitPolicy4 } from "@spfn/core/middleware";
|
|
12281
12681
|
import { defineRouter as defineRouter4, route as route4 } from "@spfn/core/route";
|
|
12282
12682
|
var providerParams = Type.Object({
|
|
@@ -12396,10 +12796,10 @@ var getGoogleOAuthUrl = route4.post("/_auth/oauth/google/url").input({
|
|
|
12396
12796
|
}).use([rateLimitPolicy4("oauth-start", { limit: 20, windowMs: 6e4 })]).skip(["auth"]).handler(async (c) => {
|
|
12397
12797
|
const { body } = await c.data();
|
|
12398
12798
|
if (!isGoogleOAuthEnabled()) {
|
|
12399
|
-
throw new
|
|
12799
|
+
throw new ValidationError9({ message: "Google OAuth is not configured" });
|
|
12400
12800
|
}
|
|
12401
12801
|
if (!body.state) {
|
|
12402
|
-
throw new
|
|
12802
|
+
throw new ValidationError9({
|
|
12403
12803
|
message: "OAuth state is required. Ensure the OAuth interceptor is configured."
|
|
12404
12804
|
});
|
|
12405
12805
|
}
|
|
@@ -12494,7 +12894,7 @@ var getProviderOAuthUrl = route4.post("/_auth/oauth/:provider/url").input({
|
|
|
12494
12894
|
const { params, body } = await c.data();
|
|
12495
12895
|
const provider = requireEnabledProvider(params.provider);
|
|
12496
12896
|
if (!body.state) {
|
|
12497
|
-
throw new
|
|
12897
|
+
throw new ValidationError9({
|
|
12498
12898
|
message: "OAuth state is required. Ensure the OAuth interceptor is configured."
|
|
12499
12899
|
});
|
|
12500
12900
|
}
|
|
@@ -12504,13 +12904,20 @@ var oauthNative = route4.post("/_auth/oauth/:provider/native").input({
|
|
|
12504
12904
|
params: providerParams,
|
|
12505
12905
|
body: Type.Object({
|
|
12506
12906
|
idToken: Type.String({ description: "id_token from native/web social SDK" }),
|
|
12507
|
-
nonce: Type.String({
|
|
12907
|
+
nonce: Type.String({
|
|
12908
|
+
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."
|
|
12909
|
+
}),
|
|
12910
|
+
accessToken: Type.Optional(Type.String({
|
|
12911
|
+
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)."
|
|
12912
|
+
})),
|
|
12508
12913
|
publicKey: Type.String({ description: "Client public key (Base64 DER)" }),
|
|
12509
12914
|
keyId: Type.String({ description: "Key identifier (UUID)" }),
|
|
12510
12915
|
fingerprint: Type.String({ description: "Key fingerprint (SHA-256 hex)" }),
|
|
12511
12916
|
algorithm: Type.Union(KEY_ALGORITHM.map((a) => Type.Literal(a)), {
|
|
12512
12917
|
description: "Key algorithm (ES256 or RS256)"
|
|
12513
12918
|
}),
|
|
12919
|
+
deviceName: Type.Optional(DeviceNameSchema),
|
|
12920
|
+
platform: Type.Optional(PlatformSchema),
|
|
12514
12921
|
profile: Type.Optional(Type.Object({
|
|
12515
12922
|
name: Type.Optional(Type.String())
|
|
12516
12923
|
}, {
|
|
@@ -12520,7 +12927,11 @@ var oauthNative = route4.post("/_auth/oauth/:provider/native").input({
|
|
|
12520
12927
|
description: "Custom metadata passed to auth events (e.g. referral code, UTM params)"
|
|
12521
12928
|
}))
|
|
12522
12929
|
})
|
|
12523
|
-
}).use([rateLimitPolicy4("oauth-native", {
|
|
12930
|
+
}).use([rateLimitPolicy4("oauth-native", {
|
|
12931
|
+
limit: 5,
|
|
12932
|
+
windowMs: 6e4,
|
|
12933
|
+
by: byIpAndIdToken({ ipLimit: 20 })
|
|
12934
|
+
})]).skip(["auth"]).handler(async (c) => {
|
|
12524
12935
|
const { params, body } = await c.data();
|
|
12525
12936
|
return await oauthNativeService({ provider: params.provider, ...body });
|
|
12526
12937
|
});
|
|
@@ -12729,6 +13140,9 @@ var mainAuthRouter = defineRouter6({
|
|
|
12729
13140
|
login,
|
|
12730
13141
|
logout,
|
|
12731
13142
|
rotateKey,
|
|
13143
|
+
listKeys,
|
|
13144
|
+
revokeKey,
|
|
13145
|
+
revokeAllKeys,
|
|
12732
13146
|
changePassword,
|
|
12733
13147
|
getAuthSession,
|
|
12734
13148
|
// One-Time Token routes
|
|
@@ -13134,16 +13548,21 @@ export {
|
|
|
13134
13548
|
DEFAULT_DELETION_PURGE_CRON,
|
|
13135
13549
|
DEFAULT_DELETION_PURGE_STRATEGY,
|
|
13136
13550
|
DEFAULT_DELETION_SEND_NOTIFICATIONS,
|
|
13551
|
+
DeviceNameSchema,
|
|
13137
13552
|
EmailSchema,
|
|
13138
13553
|
EnvironmentKeyringTokenCipher,
|
|
13139
13554
|
INVITATION_STATUSES,
|
|
13140
13555
|
InvitationsRepository,
|
|
13141
13556
|
KEY_ALGORITHM,
|
|
13557
|
+
KEY_DEVICE_NAME_MAX_LENGTH,
|
|
13558
|
+
KEY_FINGERPRINT_PREFIX_LENGTH,
|
|
13559
|
+
KEY_PLATFORM,
|
|
13142
13560
|
KeysRepository,
|
|
13143
13561
|
PURGE_STRATEGIES,
|
|
13144
13562
|
PasswordSchema,
|
|
13145
13563
|
PermissionsRepository,
|
|
13146
13564
|
PhoneSchema,
|
|
13565
|
+
PlatformSchema,
|
|
13147
13566
|
RolePermissionsRepository,
|
|
13148
13567
|
RolesRepository,
|
|
13149
13568
|
SOCIAL_PROVIDERS,
|
|
@@ -13257,6 +13676,7 @@ export {
|
|
|
13257
13676
|
kakaoProvider,
|
|
13258
13677
|
keysRepository,
|
|
13259
13678
|
listInvitations,
|
|
13679
|
+
listKeysService,
|
|
13260
13680
|
loginService,
|
|
13261
13681
|
logoutService,
|
|
13262
13682
|
matchOAuthCsrfCookies,
|
|
@@ -13284,6 +13704,7 @@ export {
|
|
|
13284
13704
|
requireRole,
|
|
13285
13705
|
resendInvitation,
|
|
13286
13706
|
resolveAuthenticatedUser,
|
|
13707
|
+
revokeAllKeysService,
|
|
13287
13708
|
revokeKeyService,
|
|
13288
13709
|
roleGuard,
|
|
13289
13710
|
rolePermissions,
|