@spfn/auth 0.2.0-beta.82 → 0.2.0-beta.84

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.d.ts CHANGED
@@ -1,5 +1,5 @@
1
- import { j as AuthInitOptions, k as OAuthProvider, e as VerificationPurpose, i as PermissionCategory, l as AuthContext } from './authenticate-B6CbndRk.js';
2
- export { C as ChangePasswordParams, a3 as EmailSchema, I as IssueOneTimeTokenResult, s as LoginParams, L as LoginResult, t as LogoutParams, aa as NativeVerifyOptions, a8 as NormalizedIdentity, Z as OAuthCallbackParams, _ as OAuthCallbackResult, ab as OAuthCodeExchangeOptions, a0 as OAuthNativeParams, c as OAuthNativeResult, Y as OAuthStartParams, O as OAuthStartResult, a9 as OAuthTokens, a5 as PasswordSchema, a4 as PhoneSchema, q as RegisterParams, E as RegisterPublicKeyParams, a as RegisterResult, G as RevokeKeyParams, F as RotateKeyParams, b as RotateKeyResult, w as SendVerificationCodeParams, S as SendVerificationCodeResult, a6 as TargetTypeSchema, g as VERIFICATION_PURPOSES, f as VERIFICATION_TARGET_TYPES, a7 as VerificationPurposeSchema, V as VerificationTargetType, x as VerifyCodeParams, y as VerifyCodeResult, m as authRouter, a1 as authenticate, N as buildOAuthErrorUrl, p as changePasswordService, W as getEnabledOAuthProviders, X as getGoogleAccessToken, ad as getOAuthProvider, ae as getRegisteredProviders, Q as isOAuthProviderEnabled, H as issueOneTimeTokenService, n as loginService, o as logoutService, M as oauthCallbackService, $ as oauthNativeService, K as oauthStartService, a2 as optionalAuth, ac as registerOAuthProvider, z as registerPublicKeyService, r as registerService, T as requireEnabledProvider, D as revokeKeyService, B as rotateKeyService, u as sendVerificationCodeService, v as verifyCodeService, J as verifyOneTimeTokenService } from './authenticate-B6CbndRk.js';
1
+ import { j as AuthInitOptions, k as OAuthProvider, e as VerificationPurpose, i as PermissionCategory, l as AuthContext } from './authenticate-ngWdlWWL.js';
2
+ export { C as ChangePasswordParams, a5 as EmailSchema, I as IssueOneTimeTokenResult, s as LoginParams, L as LoginResult, t as LogoutParams, ac as NativeVerifyOptions, aa as NormalizedIdentity, _ as OAuthCallbackParams, $ as OAuthCallbackResult, ad as OAuthCodeExchangeOptions, a2 as OAuthNativeParams, c as OAuthNativeResult, Z as OAuthStartParams, O as OAuthStartResult, ab as OAuthTokens, a7 as PasswordSchema, a6 as PhoneSchema, q as RegisterParams, E as RegisterPublicKeyParams, a as RegisterResult, G as RevokeKeyParams, F as RotateKeyParams, b as RotateKeyResult, w as SendVerificationCodeParams, S as SendVerificationCodeResult, a8 as TargetTypeSchema, af as UnlinkNotification, ag as UnlinkNotifyRejection, ae as UnlinkNotifyRequest, a0 as UnlinkNotifyResult, g as VERIFICATION_PURPOSES, f as VERIFICATION_TARGET_TYPES, a9 as VerificationPurposeSchema, V as VerificationTargetType, x as VerifyCodeParams, y as VerifyCodeResult, m as authRouter, a3 as authenticate, N as buildOAuthErrorUrl, p as changePasswordService, W as getEnabledOAuthProviders, X as getGoogleAccessToken, ai as getOAuthProvider, aj as getRegisteredProviders, Q as isOAuthProviderEnabled, H as issueOneTimeTokenService, n as loginService, o as logoutService, M as oauthCallbackService, a1 as oauthNativeService, K as oauthStartService, Y as oauthUnlinkNotifyService, a4 as optionalAuth, ah as registerOAuthProvider, z as registerPublicKeyService, r as registerService, T as requireEnabledProvider, D as revokeKeyService, B as rotateKeyService, u as sendVerificationCodeService, v as verifyCodeService, J as verifyOneTimeTokenService } from './authenticate-ngWdlWWL.js';
3
3
  import * as drizzle_orm_pg_core from 'drizzle-orm/pg-core';
4
4
  import { K as KeyAlgorithmType, c as InvitationStatus, e as SocialProvider, g as AccountDeletionRequestedBy, h as PurgeStrategy } from './types-1BMx0OX1.js';
5
5
  export { b as ACCOUNT_DELETION_REQUESTED_BY, A as ACCOUNT_DELETION_REQUEST_STATUSES, f as AccountDeletionRequestStatus, I as INVITATION_STATUSES, a as KEY_ALGORITHM, P as PURGE_STRATEGIES, S as SOCIAL_PROVIDERS, U as USER_STATUSES, d as UserStatus } from './types-1BMx0OX1.js';
@@ -6434,6 +6434,29 @@ declare const authDeletionCompletedEvent: _spfn_core_event.EventDef<{
6434
6434
  userPublicId: string;
6435
6435
  purgeStrategy: "anonymize" | "hard-delete";
6436
6436
  }>;
6437
+ /**
6438
+ * auth.oauth.unlinked - provider발 연동 해제 이벤트
6439
+ *
6440
+ * 발행 시점:
6441
+ * - provider(카카오·네이버 등)가 unlink-notify 웹훅으로 연동 해제를 알려와
6442
+ * 소셜 계정 연결과 저장 토큰이 삭제된 직후
6443
+ *
6444
+ * 연결 삭제까지는 프레임워크가 수행하고, 그 이후(계정 탈퇴로 이어갈지 등)는
6445
+ * 앱 정책이므로 이 이벤트를 구독해 처리한다.
6446
+ *
6447
+ * @example
6448
+ * ```typescript
6449
+ * oauthUnlinkedEvent.subscribe(async (payload) => {
6450
+ * await requestAccountDeletionService({ userId: payload.userId, requestedBy: 'self' });
6451
+ * });
6452
+ * ```
6453
+ */
6454
+ declare const oauthUnlinkedEvent: _spfn_core_event.EventDef<{
6455
+ reason?: string | undefined;
6456
+ userId: string;
6457
+ provider: "email" | "phone" | "google" | "apple" | "github" | "kakao" | "naver" | "superself";
6458
+ providerUserId: string;
6459
+ }>;
6437
6460
  /**
6438
6461
  * Auth event payload types
6439
6462
  */
@@ -6444,5 +6467,6 @@ type InvitationAcceptedPayload = typeof invitationAcceptedEvent._payload;
6444
6467
  type AuthDeletionRequestedPayload = typeof authDeletionRequestedEvent._payload;
6445
6468
  type AuthDeletionCancelledPayload = typeof authDeletionCancelledEvent._payload;
6446
6469
  type AuthDeletionCompletedPayload = typeof authDeletionCompletedEvent._payload;
6470
+ type OAuthUnlinkedPayload = typeof oauthUnlinkedEvent._payload;
6447
6471
 
6448
- export { type AccountDeletionPurgeUser, type AccountDeletionRequest, AccountDeletionRequestedBy, AccountDeletionRequestsRepository, type AuthConfig, AuthContext, type AuthDeletionCancelledPayload, type AuthDeletionCompletedPayload, type AuthDeletionConfig, type AuthDeletionRequestedPayload, type AuthLifecycleConfig, type AuthLifecycleOptions, type AuthLoginPayload, type AuthMetadataEntity, AuthMetadataRepository, AuthProviderSchema, type AuthRegisterPayload, type BeforeRegisterContext, COOKIE_NAMES, type CancelAccountDeletionParams, type CancelAccountDeletionResult, type CreateOAuthStateParams, DEFAULT_DELETION_ALLOW_SELF_IMMEDIATE, DEFAULT_DELETION_GRACE_PERIOD_DAYS, DEFAULT_DELETION_PURGE_CRON, DEFAULT_DELETION_PURGE_STRATEGY, DEFAULT_DELETION_SEND_NOTIFICATIONS, type DecryptedOAuthToken, EnvironmentKeyringTokenCipher, type GoogleTokenResponse, type GoogleUserInfo, type Invitation, type InvitationAcceptedPayload, type InvitationCreatedPayload, InvitationStatus, InvitationsRepository, KeyAlgorithmType, type KeyPair, KeysRepository, type NewAccountDeletionRequest, type NewAuthMetadataEntity, type NewInvitation, type NewPermission, type NewPermissionEntity, type NewRole, type NewRoleEntity, type NewRolePermission, type NewUser, type NewUserPermission, type NewUserProfile, type NewUserPublicKey, type NewUserSocialAccount, type NewVerificationCode, OAuthProvider, type OAuthState, type OAuthTokenCipher, type OAuthTokenContext, type OAuthTokenType, type PendingDeletionInfo, type Permission, type PermissionEntity, PermissionsRepository, PurgeStrategy, type PurgeUserResult, type RegisterChannel, type RequestAccountDeletionParams, type RequestAccountDeletionResult, type Role, type RoleEntity, type RoleGuardOptions, type RolePermission, RolePermissionsRepository, RolesRepository, type SessionPayload, SocialAccountsRepository, SocialProvider, type SweepDuePurgesResult, type TokenPayload, type UpdateProfileParams, type User, type UserPermission, UserPermissionsRepository, type UserProfile, UserProfilesRepository, type UserPublicKey, type UserSocialAccount, UsersRepository, type VerificationCode, VerificationCodesRepository, VerificationPurpose, type VerifyIdTokenParams, acceptInvitation, accountDeletionRequests, accountDeletionRequestsRepository, addPermissionToRole, appleProvider, assertCanAssignRole, authDeletionCancelledEvent, authDeletionCompletedEvent, authDeletionRequestedEvent, authJobRouter, authLogger, authLoginEvent, authMetadata, authMetadataRepository, authRegisterEvent, authSchema, cancelAccountDeletionService, cancelInvitation, checkUsernameAvailableService, configureAuth, configureDeletion, configureOAuthTokenCipher, createAuthDeletionJobRouter, createAuthDeletionPurgeJob, createAuthLifecycle, createInvitation, createOAuthState, createRole, decodeToken, decryptToken, deleteInvitation, deleteRole, encryptToken, exchangeCodeForTokens, expireOldInvitations, generateClientToken, generateKeyPair, generateKeyPairES256, generateKeyPairRS256, generateOAuthNonce, generateToken, getAllRoles, getAuth, getAuthConfig, getAuthSessionService, getDeletionConfig, getDummyPasswordHash, getGoogleAuthUrl, getGoogleOAuthConfig, getGoogleUserInfo, getInvitationByToken, getInvitationWithDetails, getKeyId, getKeySize, getLocale, getOneTimeTokenManager, getOptionalAuth, getPendingDeletionInfo, getRole, getRoleByName, getRolePermissions, getSessionTtl, getUser, getUserByEmailService, getUserByIdService, getUserByPhoneService, getUserId, getUserPermissions, getUserProfileService, getUserRole, githubProvider, googleProvider, hasAllPermissions, hasAnyPermission, hasAnyRole, hasPermission, hasRole, hashPassword, initOneTimeTokenManager, initializeAuth, invitationAcceptedEvent, invitationCreatedEvent, invitationsRepository, isEncrypted, isGoogleOAuthEnabled, kakaoProvider, keysRepository, listInvitations, matchOAuthCsrfCookies, naverProvider, oneTimeTokenAuth, parseDuration, permissions, permissionsRepository, purgeUserService, refreshAccessToken, removePermissionFromRole, requestAccountDeletionService, requireAnyPermission, requirePermissions, requireRole, resendInvitation, roleGuard, rolePermissions, rolePermissionsRepository, roles, rolesRepository, runBeforeRegister, setRolePermissions, shouldRotateKey, socialAccountsRepository, sweepDuePurges, updateLastLoginService, updateLocaleService, updateRole, updateUserProfileService, updateUserService, updateUsernameService, userInvitations, userPermissions, userPermissionsRepository, userProfiles, userProfilesRepository, userPublicKeys, userSocialAccounts, users, usersRepository, validateInvitation, validatePasswordStrength, verificationCodes, verificationCodesRepository, verifyClientToken, verifyIdToken, verifyKeyFingerprint, verifyOAuthState, verifyPassword, verifyToken };
6472
+ export { type AccountDeletionPurgeUser, type AccountDeletionRequest, AccountDeletionRequestedBy, AccountDeletionRequestsRepository, type AuthConfig, AuthContext, type AuthDeletionCancelledPayload, type AuthDeletionCompletedPayload, type AuthDeletionConfig, type AuthDeletionRequestedPayload, type AuthLifecycleConfig, type AuthLifecycleOptions, type AuthLoginPayload, type AuthMetadataEntity, AuthMetadataRepository, AuthProviderSchema, type AuthRegisterPayload, type BeforeRegisterContext, COOKIE_NAMES, type CancelAccountDeletionParams, type CancelAccountDeletionResult, type CreateOAuthStateParams, DEFAULT_DELETION_ALLOW_SELF_IMMEDIATE, DEFAULT_DELETION_GRACE_PERIOD_DAYS, DEFAULT_DELETION_PURGE_CRON, DEFAULT_DELETION_PURGE_STRATEGY, DEFAULT_DELETION_SEND_NOTIFICATIONS, type DecryptedOAuthToken, EnvironmentKeyringTokenCipher, type GoogleTokenResponse, type GoogleUserInfo, type Invitation, type InvitationAcceptedPayload, type InvitationCreatedPayload, InvitationStatus, InvitationsRepository, KeyAlgorithmType, type KeyPair, KeysRepository, type NewAccountDeletionRequest, type NewAuthMetadataEntity, type NewInvitation, type NewPermission, type NewPermissionEntity, type NewRole, type NewRoleEntity, type NewRolePermission, type NewUser, type NewUserPermission, type NewUserProfile, type NewUserPublicKey, type NewUserSocialAccount, type NewVerificationCode, OAuthProvider, type OAuthState, type OAuthTokenCipher, type OAuthTokenContext, type OAuthTokenType, type OAuthUnlinkedPayload, type PendingDeletionInfo, type Permission, type PermissionEntity, PermissionsRepository, PurgeStrategy, type PurgeUserResult, type RegisterChannel, type RequestAccountDeletionParams, type RequestAccountDeletionResult, type Role, type RoleEntity, type RoleGuardOptions, type RolePermission, RolePermissionsRepository, RolesRepository, type SessionPayload, SocialAccountsRepository, SocialProvider, type SweepDuePurgesResult, type TokenPayload, type UpdateProfileParams, type User, type UserPermission, UserPermissionsRepository, type UserProfile, UserProfilesRepository, type UserPublicKey, type UserSocialAccount, UsersRepository, type VerificationCode, VerificationCodesRepository, VerificationPurpose, type VerifyIdTokenParams, acceptInvitation, accountDeletionRequests, accountDeletionRequestsRepository, addPermissionToRole, appleProvider, assertCanAssignRole, authDeletionCancelledEvent, authDeletionCompletedEvent, authDeletionRequestedEvent, authJobRouter, authLogger, authLoginEvent, authMetadata, authMetadataRepository, authRegisterEvent, authSchema, cancelAccountDeletionService, cancelInvitation, checkUsernameAvailableService, configureAuth, configureDeletion, configureOAuthTokenCipher, createAuthDeletionJobRouter, createAuthDeletionPurgeJob, createAuthLifecycle, createInvitation, createOAuthState, createRole, decodeToken, decryptToken, deleteInvitation, deleteRole, encryptToken, exchangeCodeForTokens, expireOldInvitations, generateClientToken, generateKeyPair, generateKeyPairES256, generateKeyPairRS256, generateOAuthNonce, generateToken, getAllRoles, getAuth, getAuthConfig, getAuthSessionService, getDeletionConfig, getDummyPasswordHash, getGoogleAuthUrl, getGoogleOAuthConfig, getGoogleUserInfo, getInvitationByToken, getInvitationWithDetails, getKeyId, getKeySize, getLocale, getOneTimeTokenManager, getOptionalAuth, getPendingDeletionInfo, getRole, getRoleByName, getRolePermissions, getSessionTtl, getUser, getUserByEmailService, getUserByIdService, getUserByPhoneService, getUserId, getUserPermissions, getUserProfileService, getUserRole, githubProvider, googleProvider, hasAllPermissions, hasAnyPermission, hasAnyRole, hasPermission, hasRole, hashPassword, initOneTimeTokenManager, initializeAuth, invitationAcceptedEvent, invitationCreatedEvent, invitationsRepository, isEncrypted, isGoogleOAuthEnabled, kakaoProvider, keysRepository, listInvitations, matchOAuthCsrfCookies, naverProvider, oauthUnlinkedEvent, oneTimeTokenAuth, parseDuration, permissions, permissionsRepository, purgeUserService, refreshAccessToken, removePermissionFromRole, requestAccountDeletionService, requireAnyPermission, requirePermissions, requireRole, resendInvitation, roleGuard, rolePermissions, rolePermissionsRepository, roles, rolesRepository, runBeforeRegister, setRolePermissions, shouldRotateKey, socialAccountsRepository, sweepDuePurges, updateLastLoginService, updateLocaleService, updateRole, updateUserProfileService, updateUserService, updateUsernameService, userInvitations, userPermissions, userPermissionsRepository, userProfiles, userProfilesRepository, userPublicKeys, userSocialAccounts, users, usersRepository, validateInvitation, validatePasswordStrength, verificationCodes, verificationCodesRepository, verifyClientToken, verifyIdToken, verifyKeyFingerprint, verifyOAuthState, verifyPassword, verifyToken };
package/dist/server.js CHANGED
@@ -6937,6 +6937,14 @@ var init_schema5 = __esm({
6937
6937
  examples: ["your-kakao-client-secret"]
6938
6938
  })
6939
6939
  },
6940
+ SPFN_AUTH_KAKAO_ADMIN_KEY: {
6941
+ ...envString({
6942
+ description: "Kakao app admin key. Required to verify the User Unlinked webhook (Authorization: KakaoAK header).",
6943
+ required: false,
6944
+ sensitive: true,
6945
+ examples: ["your-kakao-admin-key"]
6946
+ })
6947
+ },
6940
6948
  SPFN_AUTH_KAKAO_SCOPES: {
6941
6949
  ...envString({
6942
6950
  description: "Comma-separated Kakao consent scopes. Defaults to account_email.",
@@ -8398,6 +8406,16 @@ var authDeletionCompletedEvent = defineEvent(
8398
8406
  purgeStrategy: Type.Union([Type.Literal("anonymize"), Type.Literal("hard-delete")])
8399
8407
  })
8400
8408
  );
8409
+ var oauthUnlinkedEvent = defineEvent(
8410
+ "auth.oauth.unlinked",
8411
+ Type.Object({
8412
+ userId: Type.String(),
8413
+ provider: AuthProviderSchema,
8414
+ providerUserId: Type.String(),
8415
+ /** provider가 전달한 해제 경로 (kakao referrer_type 등) */
8416
+ reason: Type.Optional(Type.String())
8417
+ })
8418
+ );
8401
8419
 
8402
8420
  // src/server/services/account-deletion.service.ts
8403
8421
  var POSTGRES_UNIQUE_VIOLATION = "23505";
@@ -9521,6 +9539,14 @@ async function verifyOAuthState(encryptedState) {
9521
9539
  }
9522
9540
 
9523
9541
  // src/server/lib/oauth/provider.ts
9542
+ var UnlinkNotifyRejection = class extends Error {
9543
+ status;
9544
+ constructor(status, message) {
9545
+ super(message);
9546
+ this.name = "UnlinkNotifyRejection";
9547
+ this.status = status;
9548
+ }
9549
+ };
9524
9550
  var registry2 = /* @__PURE__ */ new Map();
9525
9551
  function registerOAuthProvider(provider) {
9526
9552
  registry2.set(provider.id, provider);
@@ -9835,6 +9861,7 @@ registerOAuthProvider(githubProvider);
9835
9861
  // src/server/lib/oauth/kakao-provider.ts
9836
9862
  init_config();
9837
9863
  import { ValidationError as ValidationError7 } from "@spfn/core/errors";
9864
+ import { timingSafeEqual } from "crypto";
9838
9865
  var KAKAO_AUTH_URL = "https://kauth.kakao.com/oauth/authorize";
9839
9866
  var KAKAO_TOKEN_URL = "https://kauth.kakao.com/oauth/token";
9840
9867
  var KAKAO_USERINFO_URL = "https://kapi.kakao.com/v2/user/me";
@@ -9938,6 +9965,32 @@ var kakaoProvider = {
9938
9965
  params.set("client_secret", config2.clientSecret);
9939
9966
  }
9940
9967
  return requestKakaoTokens(params);
9968
+ },
9969
+ /**
9970
+ * 카카오 연결 해제 웹훅(User Unlinked) 검증
9971
+ *
9972
+ * 카카오는 `Authorization: KakaoAK ${대표 어드민 키}` 헤더로 요청하므로
9973
+ * 어드민 키 일치가 곧 발신자 검증이다. 웹훅 규격상 GET/POST 모두 가능하며
9974
+ * 본문 필드는 app_id · user_id · referrer_type.
9975
+ */
9976
+ async verifyUnlinkNotification(request) {
9977
+ const adminKey = env3.SPFN_AUTH_KAKAO_ADMIN_KEY;
9978
+ if (!adminKey) {
9979
+ throw new UnlinkNotifyRejection(401, "SPFN_AUTH_KAKAO_ADMIN_KEY is not configured");
9980
+ }
9981
+ const expected = Buffer.from(`KakaoAK ${adminKey}`);
9982
+ const actual = Buffer.from(request.authorization ?? "");
9983
+ if (expected.length !== actual.length || !timingSafeEqual(expected, actual)) {
9984
+ throw new UnlinkNotifyRejection(401, "Kakao admin key mismatch");
9985
+ }
9986
+ const userId = request.fields.user_id;
9987
+ if (!userId) {
9988
+ throw new UnlinkNotifyRejection(400, "Kakao unlink webhook is missing user_id");
9989
+ }
9990
+ return {
9991
+ providerUserId: userId,
9992
+ reason: request.fields.referrer_type
9993
+ };
9941
9994
  }
9942
9995
  };
9943
9996
  registerOAuthProvider(kakaoProvider);
@@ -9945,6 +9998,7 @@ registerOAuthProvider(kakaoProvider);
9945
9998
  // src/server/lib/oauth/naver-provider.ts
9946
9999
  init_config();
9947
10000
  import { ValidationError as ValidationError8 } from "@spfn/core/errors";
10001
+ import { createDecipheriv, createHash as createHash3, createHmac, timingSafeEqual as timingSafeEqual2 } from "crypto";
9948
10002
  var NAVER_AUTH_URL = "https://nid.naver.com/oauth2.0/authorize";
9949
10003
  var NAVER_TOKEN_URL = "https://nid.naver.com/oauth2.0/token";
9950
10004
  var NAVER_USERINFO_URL = "https://openapi.naver.com/v1/nid/me";
@@ -9983,6 +10037,25 @@ async function requestNaverTokens(params) {
9983
10037
  expiresIn
9984
10038
  };
9985
10039
  }
10040
+ function deriveNaverUnlinkKey(clientSecret) {
10041
+ return createHash3("md5").update(clientSecret).digest().subarray(0, 16);
10042
+ }
10043
+ function decodeBase64Url(value) {
10044
+ return Buffer.from(value.replace(/-/g, "+").replace(/_/g, "/"), "base64");
10045
+ }
10046
+ function decryptNaverUniqueId(encryptUniqueId, key) {
10047
+ try {
10048
+ const payload = decodeBase64Url(encryptUniqueId);
10049
+ const decipher = createDecipheriv("aes-128-cbc", key, payload.subarray(0, 16));
10050
+ const uniqueId = Buffer.concat([
10051
+ decipher.update(payload.subarray(16)),
10052
+ decipher.final()
10053
+ ]).toString("utf8");
10054
+ return uniqueId || null;
10055
+ } catch {
10056
+ return null;
10057
+ }
10058
+ }
9986
10059
  var naverProvider = {
9987
10060
  id: "naver",
9988
10061
  isEnabled() {
@@ -10024,8 +10097,9 @@ var naverProvider = {
10024
10097
  return {
10025
10098
  providerUserId: profile.id,
10026
10099
  email: typeof profile.email === "string" ? profile.email : null,
10027
- // Naver returns an email address but no independently verified-email claim.
10028
- emailVerified: false,
10100
+ // 네이버 프로필 이메일은 네이버 계정 이메일이거나 인증 절차를 거친 연락처
10101
+ // 이메일이다 — 존재하면 검증된 것으로 취급한다(카카오와 같은 신뢰 수준).
10102
+ emailVerified: typeof profile.email === "string",
10029
10103
  name: typeof profile.name === "string" ? profile.name : typeof profile.nickname === "string" ? profile.nickname : void 0,
10030
10104
  avatar: typeof profile.profile_image === "string" ? profile.profile_image : void 0
10031
10105
  };
@@ -10038,6 +10112,46 @@ var naverProvider = {
10038
10112
  client_secret: config2.clientSecret,
10039
10113
  refresh_token: refreshToken
10040
10114
  }));
10115
+ },
10116
+ // 네이버 연결 끊기 알림 규격은 성공 응답으로 204 No Content를 요구한다.
10117
+ unlinkNotifyAckStatus: 204,
10118
+ /**
10119
+ * 네이버 연결 끊기 알림 검증
10120
+ *
10121
+ * 파라미터: clientId · encryptUniqueId · timestamp · signature.
10122
+ * signature = base64url( HMAC-SHA256( "clientId=..&encryptUniqueId=..&timestamp=..", key ) ),
10123
+ * encryptUniqueId = base64url( iv(16) + AES-128-CBC-PKCS5(uniqueId, key) ),
10124
+ * key = md5(CLIENT_SECRET)[0..16].
10125
+ *
10126
+ * 복호화된 uniqueId는 프로필 API(/v1/nid/me)의 id와 동일한 이용자 고유 식별자다.
10127
+ * 네이버는 실패 요청을 재시도하지 않으므로 재전송(replay) 방어는 검증 통과 후
10128
+ * 삭제가 멱등이라는 점에 의존한다.
10129
+ */
10130
+ async verifyUnlinkNotification(request) {
10131
+ const clientId = env3.SPFN_AUTH_NAVER_CLIENT_ID;
10132
+ const clientSecret = env3.SPFN_AUTH_NAVER_CLIENT_SECRET;
10133
+ if (!clientId || !clientSecret) {
10134
+ throw new UnlinkNotifyRejection(403, "Naver OAuth is not configured");
10135
+ }
10136
+ const { clientId: requestClientId, encryptUniqueId, timestamp: timestamp2, signature } = request.fields;
10137
+ if (!requestClientId || !encryptUniqueId || !timestamp2 || !signature) {
10138
+ throw new UnlinkNotifyRejection(400, "Naver unlink notification is missing required parameters");
10139
+ }
10140
+ if (requestClientId !== clientId) {
10141
+ throw new UnlinkNotifyRejection(403, "Naver unlink notification clientId mismatch");
10142
+ }
10143
+ const key = deriveNaverUnlinkKey(clientSecret);
10144
+ const baseString = `clientId=${requestClientId}&encryptUniqueId=${encryptUniqueId}&timestamp=${timestamp2}`;
10145
+ const expected = createHmac("sha256", key).update(baseString).digest();
10146
+ const actual = decodeBase64Url(signature);
10147
+ if (expected.length !== actual.length || !timingSafeEqual2(expected, actual)) {
10148
+ throw new UnlinkNotifyRejection(403, "Naver unlink notification signature mismatch");
10149
+ }
10150
+ const uniqueId = decryptNaverUniqueId(encryptUniqueId, key);
10151
+ if (!uniqueId) {
10152
+ throw new UnlinkNotifyRejection(400, "Naver encryptUniqueId cannot be decrypted");
10153
+ }
10154
+ return { providerUserId: uniqueId, reason: "NAVER_UNLINK" };
10041
10155
  }
10042
10156
  };
10043
10157
  registerOAuthProvider(naverProvider);
@@ -10110,6 +10224,7 @@ async function oauthCallbackService(params) {
10110
10224
  refreshToken: tokens.refreshToken ?? existingSocialAccount.refreshToken,
10111
10225
  tokenExpiresAt: tokenExpiryDate(tokens.expiresIn)
10112
10226
  });
10227
+ await backfillVerifiedEmail(userId, identity);
10113
10228
  } else {
10114
10229
  const result = await createOrLinkUser(provider, identity, tokens, stateData.metadata);
10115
10230
  userId = result.userId;
@@ -10167,6 +10282,30 @@ async function assertActiveForOAuthSession(userId) {
10167
10282
  }
10168
10283
  throw new AccountDisabledError2({ status: user.status });
10169
10284
  }
10285
+ async function backfillVerifiedEmail(userId, identity) {
10286
+ if (!identity.email || !identity.emailVerified) {
10287
+ return;
10288
+ }
10289
+ const user = await usersRepository.findById(userId);
10290
+ if (!user || user.email) {
10291
+ return;
10292
+ }
10293
+ const emailOwner = await usersRepository.findByEmail(identity.email);
10294
+ if (emailOwner && emailOwner.id !== userId) {
10295
+ return;
10296
+ }
10297
+ try {
10298
+ await usersRepository.updateById(userId, {
10299
+ email: identity.email,
10300
+ emailVerifiedAt: /* @__PURE__ */ new Date()
10301
+ });
10302
+ } catch (error) {
10303
+ authLogger.service.warn("Verified-email backfill failed; continuing login", {
10304
+ userId: String(userId),
10305
+ error: error instanceof Error ? error.message : String(error)
10306
+ });
10307
+ }
10308
+ }
10170
10309
  async function createOrLinkUser(provider, identity, tokens, metadata) {
10171
10310
  const existingUser = identity.email ? await usersRepository.findByEmail(identity.email) : null;
10172
10311
  let userId;
@@ -10265,6 +10404,25 @@ async function getGoogleAccessToken(userId) {
10265
10404
  });
10266
10405
  return tokens.access_token;
10267
10406
  }
10407
+ async function oauthUnlinkNotifyService(provider, notification) {
10408
+ const oauthProvider = requireEnabledProvider(provider);
10409
+ const ackStatus = oauthProvider.unlinkNotifyAckStatus ?? 200;
10410
+ const account = await socialAccountsRepository.findByProviderAndProviderId(
10411
+ provider,
10412
+ notification.providerUserId
10413
+ );
10414
+ if (!account) {
10415
+ return { ackStatus, handled: false };
10416
+ }
10417
+ await socialAccountsRepository.deleteById(account.id);
10418
+ await oauthUnlinkedEvent.emit({
10419
+ userId: String(account.userId),
10420
+ provider,
10421
+ providerUserId: notification.providerUserId,
10422
+ reason: notification.reason
10423
+ });
10424
+ return { ackStatus, handled: true };
10425
+ }
10268
10426
 
10269
10427
  // src/server/services/oauth-native.service.ts
10270
10428
  init_repositories();
@@ -10293,6 +10451,7 @@ async function persistNativeLogin(identity, params) {
10293
10451
  let isNewUser = false;
10294
10452
  if (existing) {
10295
10453
  userId = existing.userId;
10454
+ await backfillVerifiedEmail(userId, identity);
10296
10455
  } else {
10297
10456
  const result = await createOrLinkUser(params.provider, identity, void 0, params.metadata);
10298
10457
  userId = result.userId;
@@ -11558,6 +11717,56 @@ var oauthNative = route4.post("/_auth/oauth/:provider/native").input({
11558
11717
  const { params, body } = await c.data();
11559
11718
  return await oauthNativeService({ provider: params.provider, ...body });
11560
11719
  });
11720
+ async function collectUnlinkNotifyFields(raw) {
11721
+ const fields = { ...raw.req.query() };
11722
+ if (raw.req.method === "GET") {
11723
+ return fields;
11724
+ }
11725
+ const contentType = raw.req.header("content-type") ?? "";
11726
+ if (contentType.includes("application/json")) {
11727
+ const body2 = await raw.req.json().catch(() => null);
11728
+ for (const [key, value] of Object.entries(body2 ?? {})) {
11729
+ if (typeof value === "string" || typeof value === "number") {
11730
+ fields[key] = String(value);
11731
+ }
11732
+ }
11733
+ return fields;
11734
+ }
11735
+ const body = await raw.req.parseBody().catch(() => ({}));
11736
+ for (const [key, value] of Object.entries(body)) {
11737
+ if (typeof value === "string") {
11738
+ fields[key] = value;
11739
+ }
11740
+ }
11741
+ return fields;
11742
+ }
11743
+ async function processUnlinkNotify(provider, raw) {
11744
+ const oauthProvider = getOAuthProvider(provider);
11745
+ if (!oauthProvider?.isEnabled() || !oauthProvider.verifyUnlinkNotification) {
11746
+ return 404;
11747
+ }
11748
+ const request = {
11749
+ authorization: raw.req.header("authorization") ?? null,
11750
+ fields: await collectUnlinkNotifyFields(raw)
11751
+ };
11752
+ try {
11753
+ const notification = await oauthProvider.verifyUnlinkNotification(request);
11754
+ const result = await oauthUnlinkNotifyService(provider, notification);
11755
+ return result.ackStatus;
11756
+ } catch (error) {
11757
+ return error instanceof UnlinkNotifyRejection ? error.status : 400;
11758
+ }
11759
+ }
11760
+ var oauthUnlinkNotify = route4.post("/_auth/oauth/:provider/unlink-notify").input({ params: providerParams }).use([rateLimitPolicy4("oauth-unlink-notify", { limit: 300, windowMs: 6e4 })]).skip(["auth"]).handler(async (c) => {
11761
+ const { params } = await c.data();
11762
+ const status = await processUnlinkNotify(params.provider, c.raw);
11763
+ return status === 204 ? c.noContent() : c.json({}, status);
11764
+ });
11765
+ var oauthUnlinkNotifyGet = route4.get("/_auth/oauth/:provider/unlink-notify").input({ params: providerParams }).use([rateLimitPolicy4("oauth-unlink-notify", { limit: 300, windowMs: 6e4 })]).skip(["auth"]).handler(async (c) => {
11766
+ const { params } = await c.data();
11767
+ const status = await processUnlinkNotify(params.provider, c.raw);
11768
+ return status === 204 ? c.noContent() : c.json({}, status);
11769
+ });
11561
11770
  var oauthRouter = defineRouter4({
11562
11771
  oauthGoogleStart,
11563
11772
  oauthGoogleCallback,
@@ -11568,7 +11777,9 @@ var oauthRouter = defineRouter4({
11568
11777
  oauthProviderStart,
11569
11778
  oauthProviderCallback,
11570
11779
  getProviderOAuthUrl,
11571
- oauthNative
11780
+ oauthNative,
11781
+ oauthUnlinkNotify,
11782
+ oauthUnlinkNotifyGet
11572
11783
  });
11573
11784
 
11574
11785
  // src/server/routes/admin/index.ts
@@ -11729,6 +11940,8 @@ var mainAuthRouter = defineRouter6({
11729
11940
  oauthProviderCallback,
11730
11941
  getProviderOAuthUrl,
11731
11942
  oauthNative,
11943
+ oauthUnlinkNotify,
11944
+ oauthUnlinkNotifyGet,
11732
11945
  // Invitation routes
11733
11946
  getInvitation,
11734
11947
  acceptInvitation: acceptInvitation2,
@@ -12130,6 +12343,7 @@ export {
12130
12343
  SocialAccountsRepository,
12131
12344
  TargetTypeSchema,
12132
12345
  USER_STATUSES,
12346
+ UnlinkNotifyRejection,
12133
12347
  UserPermissionsRepository,
12134
12348
  UserProfilesRepository,
12135
12349
  UsersRepository,
@@ -12243,6 +12457,8 @@ export {
12243
12457
  oauthCallbackService,
12244
12458
  oauthNativeService,
12245
12459
  oauthStartService,
12460
+ oauthUnlinkNotifyService,
12461
+ oauthUnlinkedEvent,
12246
12462
  oneTimeTokenAuth,
12247
12463
  optionalAuth,
12248
12464
  parseDuration,