@spfn/auth 0.2.0-beta.11 → 0.2.0-beta.12
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 +294 -8
- package/dist/{authenticate-CU6_zQaa.d.ts → authenticate-xfEpwIjH.d.ts} +103 -1
- package/dist/config.d.ts +104 -0
- package/dist/config.js +61 -0
- package/dist/config.js.map +1 -1
- package/dist/index.d.ts +46 -2
- package/dist/nextjs/api.js +186 -0
- package/dist/nextjs/api.js.map +1 -1
- package/dist/nextjs/client.js +80 -0
- package/dist/nextjs/client.js.map +1 -0
- package/dist/nextjs/server.d.ts +68 -2
- package/dist/nextjs/server.js +125 -2
- package/dist/nextjs/server.js.map +1 -1
- package/dist/server.d.ts +243 -3
- package/dist/server.js +557 -20
- package/dist/server.js.map +1 -1
- package/package.json +10 -3
package/dist/server.js
CHANGED
|
@@ -6407,6 +6407,96 @@ var init_invitations_repository = __esm({
|
|
|
6407
6407
|
}
|
|
6408
6408
|
});
|
|
6409
6409
|
|
|
6410
|
+
// src/server/repositories/social-accounts.repository.ts
|
|
6411
|
+
import { eq as eq10, and as and7 } from "drizzle-orm";
|
|
6412
|
+
import { BaseRepository as BaseRepository10 } from "@spfn/core/db";
|
|
6413
|
+
var SocialAccountsRepository, socialAccountsRepository;
|
|
6414
|
+
var init_social_accounts_repository = __esm({
|
|
6415
|
+
"src/server/repositories/social-accounts.repository.ts"() {
|
|
6416
|
+
"use strict";
|
|
6417
|
+
init_entities();
|
|
6418
|
+
SocialAccountsRepository = class extends BaseRepository10 {
|
|
6419
|
+
/**
|
|
6420
|
+
* provider와 providerUserId로 소셜 계정 조회
|
|
6421
|
+
* Read replica 사용
|
|
6422
|
+
*/
|
|
6423
|
+
async findByProviderAndProviderId(provider, providerUserId) {
|
|
6424
|
+
const result = await this.readDb.select().from(userSocialAccounts).where(
|
|
6425
|
+
and7(
|
|
6426
|
+
eq10(userSocialAccounts.provider, provider),
|
|
6427
|
+
eq10(userSocialAccounts.providerUserId, providerUserId)
|
|
6428
|
+
)
|
|
6429
|
+
).limit(1);
|
|
6430
|
+
return result[0] ?? null;
|
|
6431
|
+
}
|
|
6432
|
+
/**
|
|
6433
|
+
* userId로 모든 소셜 계정 조회
|
|
6434
|
+
* Read replica 사용
|
|
6435
|
+
*/
|
|
6436
|
+
async findByUserId(userId) {
|
|
6437
|
+
return await this.readDb.select().from(userSocialAccounts).where(eq10(userSocialAccounts.userId, userId));
|
|
6438
|
+
}
|
|
6439
|
+
/**
|
|
6440
|
+
* userId와 provider로 소셜 계정 조회
|
|
6441
|
+
* Read replica 사용
|
|
6442
|
+
*/
|
|
6443
|
+
async findByUserIdAndProvider(userId, provider) {
|
|
6444
|
+
const result = await this.readDb.select().from(userSocialAccounts).where(
|
|
6445
|
+
and7(
|
|
6446
|
+
eq10(userSocialAccounts.userId, userId),
|
|
6447
|
+
eq10(userSocialAccounts.provider, provider)
|
|
6448
|
+
)
|
|
6449
|
+
).limit(1);
|
|
6450
|
+
return result[0] ?? null;
|
|
6451
|
+
}
|
|
6452
|
+
/**
|
|
6453
|
+
* 소셜 계정 생성
|
|
6454
|
+
* Write primary 사용
|
|
6455
|
+
*/
|
|
6456
|
+
async create(data) {
|
|
6457
|
+
return await this._create(userSocialAccounts, {
|
|
6458
|
+
...data,
|
|
6459
|
+
createdAt: /* @__PURE__ */ new Date(),
|
|
6460
|
+
updatedAt: /* @__PURE__ */ new Date()
|
|
6461
|
+
});
|
|
6462
|
+
}
|
|
6463
|
+
/**
|
|
6464
|
+
* 토큰 정보 업데이트
|
|
6465
|
+
* Write primary 사용
|
|
6466
|
+
*/
|
|
6467
|
+
async updateTokens(id11, data) {
|
|
6468
|
+
const result = await this.db.update(userSocialAccounts).set({
|
|
6469
|
+
...data,
|
|
6470
|
+
updatedAt: /* @__PURE__ */ new Date()
|
|
6471
|
+
}).where(eq10(userSocialAccounts.id, id11)).returning();
|
|
6472
|
+
return result[0] ?? null;
|
|
6473
|
+
}
|
|
6474
|
+
/**
|
|
6475
|
+
* 소셜 계정 삭제
|
|
6476
|
+
* Write primary 사용
|
|
6477
|
+
*/
|
|
6478
|
+
async deleteById(id11) {
|
|
6479
|
+
const result = await this.db.delete(userSocialAccounts).where(eq10(userSocialAccounts.id, id11)).returning();
|
|
6480
|
+
return result[0] ?? null;
|
|
6481
|
+
}
|
|
6482
|
+
/**
|
|
6483
|
+
* userId와 provider로 소셜 계정 삭제
|
|
6484
|
+
* Write primary 사용
|
|
6485
|
+
*/
|
|
6486
|
+
async deleteByUserIdAndProvider(userId, provider) {
|
|
6487
|
+
const result = await this.db.delete(userSocialAccounts).where(
|
|
6488
|
+
and7(
|
|
6489
|
+
eq10(userSocialAccounts.userId, userId),
|
|
6490
|
+
eq10(userSocialAccounts.provider, provider)
|
|
6491
|
+
)
|
|
6492
|
+
).returning();
|
|
6493
|
+
return result[0] ?? null;
|
|
6494
|
+
}
|
|
6495
|
+
};
|
|
6496
|
+
socialAccountsRepository = new SocialAccountsRepository();
|
|
6497
|
+
}
|
|
6498
|
+
});
|
|
6499
|
+
|
|
6410
6500
|
// src/server/repositories/index.ts
|
|
6411
6501
|
var init_repositories = __esm({
|
|
6412
6502
|
"src/server/repositories/index.ts"() {
|
|
@@ -6420,6 +6510,7 @@ var init_repositories = __esm({
|
|
|
6420
6510
|
init_user_permissions_repository();
|
|
6421
6511
|
init_user_profiles_repository();
|
|
6422
6512
|
init_invitations_repository();
|
|
6513
|
+
init_social_accounts_repository();
|
|
6423
6514
|
}
|
|
6424
6515
|
});
|
|
6425
6516
|
|
|
@@ -6546,7 +6637,7 @@ var init_role_service = __esm({
|
|
|
6546
6637
|
import "@spfn/auth/config";
|
|
6547
6638
|
|
|
6548
6639
|
// src/server/routes/index.ts
|
|
6549
|
-
import { defineRouter as
|
|
6640
|
+
import { defineRouter as defineRouter5 } from "@spfn/core/route";
|
|
6550
6641
|
|
|
6551
6642
|
// src/server/routes/auth/index.ts
|
|
6552
6643
|
init_schema3();
|
|
@@ -7075,7 +7166,9 @@ var COOKIE_NAMES = {
|
|
|
7075
7166
|
/** Encrypted session data (userId, privateKey, keyId, algorithm) */
|
|
7076
7167
|
SESSION: "spfn_session",
|
|
7077
7168
|
/** Current key ID (for key rotation) */
|
|
7078
|
-
SESSION_KEY_ID: "spfn_session_key_id"
|
|
7169
|
+
SESSION_KEY_ID: "spfn_session_key_id",
|
|
7170
|
+
/** Pending OAuth session (privateKey, keyId, algorithm) - temporary during OAuth flow */
|
|
7171
|
+
OAUTH_PENDING: "spfn_oauth_pending"
|
|
7079
7172
|
};
|
|
7080
7173
|
function parseDuration(duration) {
|
|
7081
7174
|
if (typeof duration === "number") {
|
|
@@ -7560,6 +7653,301 @@ async function updateUserProfileService(userId, params) {
|
|
|
7560
7653
|
return profile;
|
|
7561
7654
|
}
|
|
7562
7655
|
|
|
7656
|
+
// src/server/services/oauth.service.ts
|
|
7657
|
+
init_repositories();
|
|
7658
|
+
import { env as env7 } from "@spfn/auth/config";
|
|
7659
|
+
import { ValidationError as ValidationError2 } from "@spfn/core/errors";
|
|
7660
|
+
|
|
7661
|
+
// src/server/lib/oauth/google.ts
|
|
7662
|
+
import { env as env5 } from "@spfn/auth/config";
|
|
7663
|
+
var GOOGLE_AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth";
|
|
7664
|
+
var GOOGLE_TOKEN_URL = "https://oauth2.googleapis.com/token";
|
|
7665
|
+
var GOOGLE_USERINFO_URL = "https://www.googleapis.com/oauth2/v2/userinfo";
|
|
7666
|
+
function isGoogleOAuthEnabled() {
|
|
7667
|
+
return !!(env5.SPFN_AUTH_GOOGLE_CLIENT_ID && env5.SPFN_AUTH_GOOGLE_CLIENT_SECRET);
|
|
7668
|
+
}
|
|
7669
|
+
function getGoogleOAuthConfig() {
|
|
7670
|
+
const clientId = env5.SPFN_AUTH_GOOGLE_CLIENT_ID;
|
|
7671
|
+
const clientSecret = env5.SPFN_AUTH_GOOGLE_CLIENT_SECRET;
|
|
7672
|
+
if (!clientId || !clientSecret) {
|
|
7673
|
+
throw new Error("Google OAuth is not configured. Set SPFN_AUTH_GOOGLE_CLIENT_ID and SPFN_AUTH_GOOGLE_CLIENT_SECRET.");
|
|
7674
|
+
}
|
|
7675
|
+
const redirectUri = env5.SPFN_AUTH_GOOGLE_REDIRECT_URI || `${env5.SPFN_API_URL}/_auth/oauth/google/callback`;
|
|
7676
|
+
return {
|
|
7677
|
+
clientId,
|
|
7678
|
+
clientSecret,
|
|
7679
|
+
redirectUri
|
|
7680
|
+
};
|
|
7681
|
+
}
|
|
7682
|
+
function getGoogleAuthUrl(state, scopes = ["email", "profile"]) {
|
|
7683
|
+
const config = getGoogleOAuthConfig();
|
|
7684
|
+
const params = new URLSearchParams({
|
|
7685
|
+
client_id: config.clientId,
|
|
7686
|
+
redirect_uri: config.redirectUri,
|
|
7687
|
+
response_type: "code",
|
|
7688
|
+
scope: scopes.join(" "),
|
|
7689
|
+
state,
|
|
7690
|
+
access_type: "offline",
|
|
7691
|
+
// refresh_token 받기 위해
|
|
7692
|
+
prompt: "consent"
|
|
7693
|
+
// 매번 동의 화면 표시 (refresh_token 보장)
|
|
7694
|
+
});
|
|
7695
|
+
return `${GOOGLE_AUTH_URL}?${params.toString()}`;
|
|
7696
|
+
}
|
|
7697
|
+
async function exchangeCodeForTokens(code) {
|
|
7698
|
+
const config = getGoogleOAuthConfig();
|
|
7699
|
+
const response = await fetch(GOOGLE_TOKEN_URL, {
|
|
7700
|
+
method: "POST",
|
|
7701
|
+
headers: {
|
|
7702
|
+
"Content-Type": "application/x-www-form-urlencoded"
|
|
7703
|
+
},
|
|
7704
|
+
body: new URLSearchParams({
|
|
7705
|
+
client_id: config.clientId,
|
|
7706
|
+
client_secret: config.clientSecret,
|
|
7707
|
+
redirect_uri: config.redirectUri,
|
|
7708
|
+
grant_type: "authorization_code",
|
|
7709
|
+
code
|
|
7710
|
+
})
|
|
7711
|
+
});
|
|
7712
|
+
if (!response.ok) {
|
|
7713
|
+
const error = await response.text();
|
|
7714
|
+
throw new Error(`Failed to exchange code for tokens: ${error}`);
|
|
7715
|
+
}
|
|
7716
|
+
return response.json();
|
|
7717
|
+
}
|
|
7718
|
+
async function getGoogleUserInfo(accessToken) {
|
|
7719
|
+
const response = await fetch(GOOGLE_USERINFO_URL, {
|
|
7720
|
+
headers: {
|
|
7721
|
+
Authorization: `Bearer ${accessToken}`
|
|
7722
|
+
}
|
|
7723
|
+
});
|
|
7724
|
+
if (!response.ok) {
|
|
7725
|
+
const error = await response.text();
|
|
7726
|
+
throw new Error(`Failed to get user info: ${error}`);
|
|
7727
|
+
}
|
|
7728
|
+
return response.json();
|
|
7729
|
+
}
|
|
7730
|
+
async function refreshAccessToken(refreshToken) {
|
|
7731
|
+
const config = getGoogleOAuthConfig();
|
|
7732
|
+
const response = await fetch(GOOGLE_TOKEN_URL, {
|
|
7733
|
+
method: "POST",
|
|
7734
|
+
headers: {
|
|
7735
|
+
"Content-Type": "application/x-www-form-urlencoded"
|
|
7736
|
+
},
|
|
7737
|
+
body: new URLSearchParams({
|
|
7738
|
+
client_id: config.clientId,
|
|
7739
|
+
client_secret: config.clientSecret,
|
|
7740
|
+
refresh_token: refreshToken,
|
|
7741
|
+
grant_type: "refresh_token"
|
|
7742
|
+
})
|
|
7743
|
+
});
|
|
7744
|
+
if (!response.ok) {
|
|
7745
|
+
const error = await response.text();
|
|
7746
|
+
throw new Error(`Failed to refresh access token: ${error}`);
|
|
7747
|
+
}
|
|
7748
|
+
return response.json();
|
|
7749
|
+
}
|
|
7750
|
+
|
|
7751
|
+
// src/server/lib/oauth/state.ts
|
|
7752
|
+
import * as jose from "jose";
|
|
7753
|
+
import { env as env6 } from "@spfn/auth/config";
|
|
7754
|
+
async function getStateKey() {
|
|
7755
|
+
const secret = env6.SPFN_AUTH_SESSION_SECRET;
|
|
7756
|
+
const encoder = new TextEncoder();
|
|
7757
|
+
const data = encoder.encode(`oauth-state:${secret}`);
|
|
7758
|
+
const hashBuffer = await crypto.subtle.digest("SHA-256", data);
|
|
7759
|
+
return new Uint8Array(hashBuffer);
|
|
7760
|
+
}
|
|
7761
|
+
function generateNonce() {
|
|
7762
|
+
const array = new Uint8Array(16);
|
|
7763
|
+
crypto.getRandomValues(array);
|
|
7764
|
+
return Array.from(array, (b) => b.toString(16).padStart(2, "0")).join("");
|
|
7765
|
+
}
|
|
7766
|
+
async function createOAuthState(params) {
|
|
7767
|
+
const key = await getStateKey();
|
|
7768
|
+
const state = {
|
|
7769
|
+
returnUrl: params.returnUrl,
|
|
7770
|
+
nonce: generateNonce(),
|
|
7771
|
+
provider: params.provider,
|
|
7772
|
+
publicKey: params.publicKey,
|
|
7773
|
+
keyId: params.keyId,
|
|
7774
|
+
fingerprint: params.fingerprint,
|
|
7775
|
+
algorithm: params.algorithm
|
|
7776
|
+
};
|
|
7777
|
+
const jwe = await new jose.EncryptJWT({ state }).setProtectedHeader({ alg: "dir", enc: "A256GCM" }).setIssuedAt().setExpirationTime("10m").encrypt(key);
|
|
7778
|
+
return encodeURIComponent(jwe);
|
|
7779
|
+
}
|
|
7780
|
+
async function verifyOAuthState(encryptedState) {
|
|
7781
|
+
const key = await getStateKey();
|
|
7782
|
+
const jwe = decodeURIComponent(encryptedState);
|
|
7783
|
+
const { payload } = await jose.jwtDecrypt(jwe, key);
|
|
7784
|
+
return payload.state;
|
|
7785
|
+
}
|
|
7786
|
+
|
|
7787
|
+
// src/server/services/oauth.service.ts
|
|
7788
|
+
async function oauthStartService(params) {
|
|
7789
|
+
const { provider, returnUrl, publicKey, keyId, fingerprint, algorithm } = params;
|
|
7790
|
+
if (provider === "google") {
|
|
7791
|
+
if (!isGoogleOAuthEnabled()) {
|
|
7792
|
+
throw new ValidationError2({
|
|
7793
|
+
message: "Google OAuth is not configured. Set SPFN_AUTH_GOOGLE_CLIENT_ID and SPFN_AUTH_GOOGLE_CLIENT_SECRET."
|
|
7794
|
+
});
|
|
7795
|
+
}
|
|
7796
|
+
const state = await createOAuthState({
|
|
7797
|
+
provider: "google",
|
|
7798
|
+
returnUrl,
|
|
7799
|
+
publicKey,
|
|
7800
|
+
keyId,
|
|
7801
|
+
fingerprint,
|
|
7802
|
+
algorithm
|
|
7803
|
+
});
|
|
7804
|
+
const authUrl = getGoogleAuthUrl(state);
|
|
7805
|
+
return { authUrl };
|
|
7806
|
+
}
|
|
7807
|
+
throw new ValidationError2({
|
|
7808
|
+
message: `Unsupported OAuth provider: ${provider}`
|
|
7809
|
+
});
|
|
7810
|
+
}
|
|
7811
|
+
async function oauthCallbackService(params) {
|
|
7812
|
+
const { provider, code, state } = params;
|
|
7813
|
+
const stateData = await verifyOAuthState(state);
|
|
7814
|
+
if (stateData.provider !== provider) {
|
|
7815
|
+
throw new ValidationError2({
|
|
7816
|
+
message: "OAuth state provider mismatch"
|
|
7817
|
+
});
|
|
7818
|
+
}
|
|
7819
|
+
if (provider === "google") {
|
|
7820
|
+
return handleGoogleCallback(code, stateData);
|
|
7821
|
+
}
|
|
7822
|
+
throw new ValidationError2({
|
|
7823
|
+
message: `Unsupported OAuth provider: ${provider}`
|
|
7824
|
+
});
|
|
7825
|
+
}
|
|
7826
|
+
async function handleGoogleCallback(code, stateData) {
|
|
7827
|
+
const tokens = await exchangeCodeForTokens(code);
|
|
7828
|
+
const googleUser = await getGoogleUserInfo(tokens.access_token);
|
|
7829
|
+
const existingSocialAccount = await socialAccountsRepository.findByProviderAndProviderId(
|
|
7830
|
+
"google",
|
|
7831
|
+
googleUser.id
|
|
7832
|
+
);
|
|
7833
|
+
let userId;
|
|
7834
|
+
let isNewUser = false;
|
|
7835
|
+
if (existingSocialAccount) {
|
|
7836
|
+
userId = existingSocialAccount.userId;
|
|
7837
|
+
await socialAccountsRepository.updateTokens(existingSocialAccount.id, {
|
|
7838
|
+
accessToken: tokens.access_token,
|
|
7839
|
+
refreshToken: tokens.refresh_token ?? existingSocialAccount.refreshToken,
|
|
7840
|
+
tokenExpiresAt: new Date(Date.now() + tokens.expires_in * 1e3)
|
|
7841
|
+
});
|
|
7842
|
+
} else {
|
|
7843
|
+
const result = await createOrLinkUser(googleUser, tokens);
|
|
7844
|
+
userId = result.userId;
|
|
7845
|
+
isNewUser = result.isNewUser;
|
|
7846
|
+
}
|
|
7847
|
+
await registerPublicKeyService({
|
|
7848
|
+
userId,
|
|
7849
|
+
keyId: stateData.keyId,
|
|
7850
|
+
publicKey: stateData.publicKey,
|
|
7851
|
+
fingerprint: stateData.fingerprint,
|
|
7852
|
+
algorithm: stateData.algorithm
|
|
7853
|
+
});
|
|
7854
|
+
await updateLastLoginService(userId);
|
|
7855
|
+
const appUrl = env7.SPFN_APP_URL;
|
|
7856
|
+
const callbackPath = env7.SPFN_AUTH_OAUTH_SUCCESS_URL || "/auth/callback";
|
|
7857
|
+
const callbackUrl = callbackPath.startsWith("http") ? callbackPath : `${appUrl}${callbackPath}`;
|
|
7858
|
+
const redirectUrl = buildRedirectUrl(callbackUrl, {
|
|
7859
|
+
userId: String(userId),
|
|
7860
|
+
keyId: stateData.keyId,
|
|
7861
|
+
returnUrl: stateData.returnUrl,
|
|
7862
|
+
isNewUser: String(isNewUser)
|
|
7863
|
+
});
|
|
7864
|
+
return {
|
|
7865
|
+
redirectUrl,
|
|
7866
|
+
userId: String(userId),
|
|
7867
|
+
keyId: stateData.keyId,
|
|
7868
|
+
isNewUser
|
|
7869
|
+
};
|
|
7870
|
+
}
|
|
7871
|
+
async function createOrLinkUser(googleUser, tokens) {
|
|
7872
|
+
const existingUser = googleUser.email ? await usersRepository.findByEmail(googleUser.email) : null;
|
|
7873
|
+
let userId;
|
|
7874
|
+
let isNewUser = false;
|
|
7875
|
+
if (existingUser) {
|
|
7876
|
+
if (!googleUser.verified_email) {
|
|
7877
|
+
throw new ValidationError2({
|
|
7878
|
+
message: "Cannot link to existing account with unverified email. Please verify your email with Google first."
|
|
7879
|
+
});
|
|
7880
|
+
}
|
|
7881
|
+
userId = existingUser.id;
|
|
7882
|
+
if (!existingUser.emailVerifiedAt) {
|
|
7883
|
+
await usersRepository.updateById(existingUser.id, {
|
|
7884
|
+
emailVerifiedAt: /* @__PURE__ */ new Date()
|
|
7885
|
+
});
|
|
7886
|
+
}
|
|
7887
|
+
} else {
|
|
7888
|
+
const { getRoleByName: getRoleByName3 } = await Promise.resolve().then(() => (init_role_service(), role_service_exports));
|
|
7889
|
+
const userRole = await getRoleByName3("user");
|
|
7890
|
+
if (!userRole) {
|
|
7891
|
+
throw new Error("Default user role not found. Run initializeAuth() first.");
|
|
7892
|
+
}
|
|
7893
|
+
const newUser = await usersRepository.create({
|
|
7894
|
+
email: googleUser.verified_email ? googleUser.email : null,
|
|
7895
|
+
phone: null,
|
|
7896
|
+
passwordHash: null,
|
|
7897
|
+
// OAuth 사용자는 비밀번호 없음
|
|
7898
|
+
passwordChangeRequired: false,
|
|
7899
|
+
roleId: userRole.id,
|
|
7900
|
+
status: "active",
|
|
7901
|
+
emailVerifiedAt: googleUser.verified_email ? /* @__PURE__ */ new Date() : null
|
|
7902
|
+
});
|
|
7903
|
+
userId = newUser.id;
|
|
7904
|
+
isNewUser = true;
|
|
7905
|
+
}
|
|
7906
|
+
await socialAccountsRepository.create({
|
|
7907
|
+
userId,
|
|
7908
|
+
provider: "google",
|
|
7909
|
+
providerUserId: googleUser.id,
|
|
7910
|
+
providerEmail: googleUser.email,
|
|
7911
|
+
accessToken: tokens.access_token,
|
|
7912
|
+
refreshToken: tokens.refresh_token ?? null,
|
|
7913
|
+
tokenExpiresAt: new Date(Date.now() + tokens.expires_in * 1e3)
|
|
7914
|
+
});
|
|
7915
|
+
return { userId, isNewUser };
|
|
7916
|
+
}
|
|
7917
|
+
function buildRedirectUrl(baseUrl, params) {
|
|
7918
|
+
const url = new URL(baseUrl, "http://placeholder");
|
|
7919
|
+
for (const [key, value] of Object.entries(params)) {
|
|
7920
|
+
url.searchParams.set(key, value);
|
|
7921
|
+
}
|
|
7922
|
+
if (baseUrl.startsWith("http")) {
|
|
7923
|
+
return url.toString();
|
|
7924
|
+
}
|
|
7925
|
+
return `${url.pathname}${url.search}`;
|
|
7926
|
+
}
|
|
7927
|
+
function buildOAuthErrorUrl(error) {
|
|
7928
|
+
const errorUrl = env7.SPFN_AUTH_OAUTH_ERROR_URL || "/auth/error?error={error}";
|
|
7929
|
+
return errorUrl.replace("{error}", encodeURIComponent(error));
|
|
7930
|
+
}
|
|
7931
|
+
function isOAuthProviderEnabled(provider) {
|
|
7932
|
+
switch (provider) {
|
|
7933
|
+
case "google":
|
|
7934
|
+
return isGoogleOAuthEnabled();
|
|
7935
|
+
case "github":
|
|
7936
|
+
case "kakao":
|
|
7937
|
+
case "naver":
|
|
7938
|
+
return false;
|
|
7939
|
+
default:
|
|
7940
|
+
return false;
|
|
7941
|
+
}
|
|
7942
|
+
}
|
|
7943
|
+
function getEnabledOAuthProviders() {
|
|
7944
|
+
const providers = [];
|
|
7945
|
+
if (isGoogleOAuthEnabled()) {
|
|
7946
|
+
providers.push("google");
|
|
7947
|
+
}
|
|
7948
|
+
return providers;
|
|
7949
|
+
}
|
|
7950
|
+
|
|
7563
7951
|
// src/server/routes/auth/index.ts
|
|
7564
7952
|
init_esm();
|
|
7565
7953
|
import { Transactional } from "@spfn/core/db";
|
|
@@ -8170,8 +8558,135 @@ var userRouter = defineRouter3({
|
|
|
8170
8558
|
updateUserProfile
|
|
8171
8559
|
});
|
|
8172
8560
|
|
|
8561
|
+
// src/server/routes/oauth/index.ts
|
|
8562
|
+
init_esm();
|
|
8563
|
+
init_types();
|
|
8564
|
+
import { Transactional as Transactional2 } from "@spfn/core/db";
|
|
8565
|
+
import { defineRouter as defineRouter4, route as route4 } from "@spfn/core/route";
|
|
8566
|
+
var oauthGoogleStart = route4.get("/_auth/oauth/google").input({
|
|
8567
|
+
query: Type.Object({
|
|
8568
|
+
state: Type.String({
|
|
8569
|
+
description: "Encrypted OAuth state (returnUrl, publicKey, keyId, fingerprint, algorithm)"
|
|
8570
|
+
})
|
|
8571
|
+
})
|
|
8572
|
+
}).skip(["auth"]).handler(async (c) => {
|
|
8573
|
+
const { query } = await c.data();
|
|
8574
|
+
if (!isGoogleOAuthEnabled()) {
|
|
8575
|
+
return c.redirect(buildOAuthErrorUrl("Google OAuth is not configured"));
|
|
8576
|
+
}
|
|
8577
|
+
const authUrl = getGoogleAuthUrl(query.state);
|
|
8578
|
+
return c.redirect(authUrl);
|
|
8579
|
+
});
|
|
8580
|
+
var oauthGoogleCallback = route4.get("/_auth/oauth/google/callback").input({
|
|
8581
|
+
query: Type.Object({
|
|
8582
|
+
code: Type.Optional(Type.String({
|
|
8583
|
+
description: "Authorization code from Google"
|
|
8584
|
+
})),
|
|
8585
|
+
state: Type.Optional(Type.String({
|
|
8586
|
+
description: "OAuth state parameter"
|
|
8587
|
+
})),
|
|
8588
|
+
error: Type.Optional(Type.String({
|
|
8589
|
+
description: "Error code from Google"
|
|
8590
|
+
})),
|
|
8591
|
+
error_description: Type.Optional(Type.String({
|
|
8592
|
+
description: "Error description from Google"
|
|
8593
|
+
}))
|
|
8594
|
+
})
|
|
8595
|
+
}).use([Transactional2()]).skip(["auth"]).handler(async (c) => {
|
|
8596
|
+
const { query } = await c.data();
|
|
8597
|
+
if (query.error) {
|
|
8598
|
+
const errorMessage = query.error_description || query.error;
|
|
8599
|
+
return c.redirect(buildOAuthErrorUrl(errorMessage));
|
|
8600
|
+
}
|
|
8601
|
+
if (!query.code || !query.state) {
|
|
8602
|
+
return c.redirect(buildOAuthErrorUrl("Missing authorization code or state"));
|
|
8603
|
+
}
|
|
8604
|
+
try {
|
|
8605
|
+
const result = await oauthCallbackService({
|
|
8606
|
+
provider: "google",
|
|
8607
|
+
code: query.code,
|
|
8608
|
+
state: query.state
|
|
8609
|
+
});
|
|
8610
|
+
return c.redirect(result.redirectUrl);
|
|
8611
|
+
} catch (err) {
|
|
8612
|
+
const message = err instanceof Error ? err.message : "OAuth callback failed";
|
|
8613
|
+
return c.redirect(buildOAuthErrorUrl(message));
|
|
8614
|
+
}
|
|
8615
|
+
});
|
|
8616
|
+
var oauthStart = route4.post("/_auth/oauth/start").input({
|
|
8617
|
+
body: Type.Object({
|
|
8618
|
+
provider: Type.Union(SOCIAL_PROVIDERS.map((p) => Type.Literal(p)), {
|
|
8619
|
+
description: "OAuth provider (google, github, kakao, naver)"
|
|
8620
|
+
}),
|
|
8621
|
+
returnUrl: Type.String({
|
|
8622
|
+
description: "URL to redirect after OAuth success"
|
|
8623
|
+
}),
|
|
8624
|
+
publicKey: Type.String({
|
|
8625
|
+
description: "Client public key (Base64 DER)"
|
|
8626
|
+
}),
|
|
8627
|
+
keyId: Type.String({
|
|
8628
|
+
description: "Key identifier (UUID)"
|
|
8629
|
+
}),
|
|
8630
|
+
fingerprint: Type.String({
|
|
8631
|
+
description: "Key fingerprint (SHA-256 hex)"
|
|
8632
|
+
}),
|
|
8633
|
+
algorithm: Type.Union(KEY_ALGORITHM.map((a) => Type.Literal(a)), {
|
|
8634
|
+
description: "Key algorithm (ES256 or RS256)"
|
|
8635
|
+
})
|
|
8636
|
+
})
|
|
8637
|
+
}).skip(["auth"]).handler(async (c) => {
|
|
8638
|
+
const { body } = await c.data();
|
|
8639
|
+
const result = await oauthStartService(body);
|
|
8640
|
+
return result;
|
|
8641
|
+
});
|
|
8642
|
+
var oauthProviders = route4.get("/_auth/oauth/providers").skip(["auth"]).handler(async () => {
|
|
8643
|
+
return {
|
|
8644
|
+
providers: getEnabledOAuthProviders()
|
|
8645
|
+
};
|
|
8646
|
+
});
|
|
8647
|
+
var getGoogleOAuthUrl = route4.post("/_auth/oauth/google/url").input({
|
|
8648
|
+
body: Type.Object({
|
|
8649
|
+
returnUrl: Type.Optional(Type.String({
|
|
8650
|
+
description: "URL to redirect after OAuth success"
|
|
8651
|
+
})),
|
|
8652
|
+
state: Type.Optional(Type.String({
|
|
8653
|
+
description: "Encrypted OAuth state (injected by interceptor)"
|
|
8654
|
+
}))
|
|
8655
|
+
})
|
|
8656
|
+
}).skip(["auth"]).handler(async (c) => {
|
|
8657
|
+
const { body } = await c.data();
|
|
8658
|
+
if (!isGoogleOAuthEnabled()) {
|
|
8659
|
+
throw new Error("Google OAuth is not configured");
|
|
8660
|
+
}
|
|
8661
|
+
if (!body.state) {
|
|
8662
|
+
throw new Error("OAuth state is required. Ensure the OAuth interceptor is configured.");
|
|
8663
|
+
}
|
|
8664
|
+
return { authUrl: getGoogleAuthUrl(body.state) };
|
|
8665
|
+
});
|
|
8666
|
+
var oauthFinalize = route4.post("/_auth/oauth/finalize").input({
|
|
8667
|
+
body: Type.Object({
|
|
8668
|
+
userId: Type.String({ description: "User ID from OAuth callback" }),
|
|
8669
|
+
keyId: Type.String({ description: "Key ID from OAuth state" }),
|
|
8670
|
+
returnUrl: Type.Optional(Type.String({ description: "URL to redirect after login" }))
|
|
8671
|
+
})
|
|
8672
|
+
}).skip(["auth"]).handler(async (c) => {
|
|
8673
|
+
const { body } = await c.data();
|
|
8674
|
+
return {
|
|
8675
|
+
success: true,
|
|
8676
|
+
returnUrl: body.returnUrl || "/"
|
|
8677
|
+
};
|
|
8678
|
+
});
|
|
8679
|
+
var oauthRouter = defineRouter4({
|
|
8680
|
+
oauthGoogleStart,
|
|
8681
|
+
oauthGoogleCallback,
|
|
8682
|
+
oauthStart,
|
|
8683
|
+
oauthProviders,
|
|
8684
|
+
getGoogleOAuthUrl,
|
|
8685
|
+
oauthFinalize
|
|
8686
|
+
});
|
|
8687
|
+
|
|
8173
8688
|
// src/server/routes/index.ts
|
|
8174
|
-
var mainAuthRouter =
|
|
8689
|
+
var mainAuthRouter = defineRouter5({
|
|
8175
8690
|
// Auth routes
|
|
8176
8691
|
checkAccountExists,
|
|
8177
8692
|
sendVerificationCode,
|
|
@@ -8182,6 +8697,13 @@ var mainAuthRouter = defineRouter4({
|
|
|
8182
8697
|
rotateKey,
|
|
8183
8698
|
changePassword,
|
|
8184
8699
|
getAuthSession,
|
|
8700
|
+
// OAuth routes
|
|
8701
|
+
oauthGoogleStart,
|
|
8702
|
+
oauthGoogleCallback,
|
|
8703
|
+
oauthStart,
|
|
8704
|
+
oauthProviders,
|
|
8705
|
+
getGoogleOAuthUrl,
|
|
8706
|
+
oauthFinalize,
|
|
8185
8707
|
// Invitation routes
|
|
8186
8708
|
getInvitation,
|
|
8187
8709
|
acceptInvitation: acceptInvitation2,
|
|
@@ -8302,11 +8824,11 @@ function shouldRotateKey(createdAt, rotationDays = 90) {
|
|
|
8302
8824
|
}
|
|
8303
8825
|
|
|
8304
8826
|
// src/server/lib/session.ts
|
|
8305
|
-
import * as
|
|
8306
|
-
import { env as
|
|
8827
|
+
import * as jose2 from "jose";
|
|
8828
|
+
import { env as env8 } from "@spfn/auth/config";
|
|
8307
8829
|
import { env as coreEnv } from "@spfn/core/config";
|
|
8308
8830
|
async function getSessionSecretKey() {
|
|
8309
|
-
const secret =
|
|
8831
|
+
const secret = env8.SPFN_AUTH_SESSION_SECRET;
|
|
8310
8832
|
const encoder = new TextEncoder();
|
|
8311
8833
|
const data = encoder.encode(secret);
|
|
8312
8834
|
const hashBuffer = await crypto.subtle.digest("SHA-256", data);
|
|
@@ -8314,24 +8836,24 @@ async function getSessionSecretKey() {
|
|
|
8314
8836
|
}
|
|
8315
8837
|
async function sealSession(data, ttl = 60 * 60 * 24 * 7) {
|
|
8316
8838
|
const secret = await getSessionSecretKey();
|
|
8317
|
-
return await new
|
|
8839
|
+
return await new jose2.EncryptJWT({ data }).setProtectedHeader({ alg: "dir", enc: "A256GCM" }).setIssuedAt().setExpirationTime(`${ttl}s`).setIssuer("spfn-auth").setAudience("spfn-client").encrypt(secret);
|
|
8318
8840
|
}
|
|
8319
8841
|
async function unsealSession(jwt4) {
|
|
8320
8842
|
try {
|
|
8321
8843
|
const secret = await getSessionSecretKey();
|
|
8322
|
-
const { payload } = await
|
|
8844
|
+
const { payload } = await jose2.jwtDecrypt(jwt4, secret, {
|
|
8323
8845
|
issuer: "spfn-auth",
|
|
8324
8846
|
audience: "spfn-client"
|
|
8325
8847
|
});
|
|
8326
8848
|
return payload.data;
|
|
8327
8849
|
} catch (err) {
|
|
8328
|
-
if (err instanceof
|
|
8850
|
+
if (err instanceof jose2.errors.JWTExpired) {
|
|
8329
8851
|
throw new Error("Session expired");
|
|
8330
8852
|
}
|
|
8331
|
-
if (err instanceof
|
|
8853
|
+
if (err instanceof jose2.errors.JWEDecryptionFailed) {
|
|
8332
8854
|
throw new Error("Invalid session");
|
|
8333
8855
|
}
|
|
8334
|
-
if (err instanceof
|
|
8856
|
+
if (err instanceof jose2.errors.JWTClaimValidationFailed) {
|
|
8335
8857
|
throw new Error("Session validation failed");
|
|
8336
8858
|
}
|
|
8337
8859
|
throw new Error("Failed to unseal session");
|
|
@@ -8340,7 +8862,7 @@ async function unsealSession(jwt4) {
|
|
|
8340
8862
|
async function getSessionInfo(jwt4) {
|
|
8341
8863
|
const secret = await getSessionSecretKey();
|
|
8342
8864
|
try {
|
|
8343
|
-
const { payload } = await
|
|
8865
|
+
const { payload } = await jose2.jwtDecrypt(jwt4, secret);
|
|
8344
8866
|
return {
|
|
8345
8867
|
issuedAt: new Date(payload.iat * 1e3),
|
|
8346
8868
|
expiresAt: new Date(payload.exp * 1e3),
|
|
@@ -8364,14 +8886,14 @@ async function shouldRefreshSession(jwt4, thresholdHours = 24) {
|
|
|
8364
8886
|
}
|
|
8365
8887
|
|
|
8366
8888
|
// src/server/setup.ts
|
|
8367
|
-
import { env as
|
|
8889
|
+
import { env as env9 } from "@spfn/auth/config";
|
|
8368
8890
|
import { getRoleByName as getRoleByName2 } from "@spfn/auth/server";
|
|
8369
8891
|
init_repositories();
|
|
8370
8892
|
function parseAdminAccounts() {
|
|
8371
8893
|
const accounts = [];
|
|
8372
|
-
if (
|
|
8894
|
+
if (env9.SPFN_AUTH_ADMIN_ACCOUNTS) {
|
|
8373
8895
|
try {
|
|
8374
|
-
const accountsJson =
|
|
8896
|
+
const accountsJson = env9.SPFN_AUTH_ADMIN_ACCOUNTS;
|
|
8375
8897
|
const parsed = JSON.parse(accountsJson);
|
|
8376
8898
|
if (!Array.isArray(parsed)) {
|
|
8377
8899
|
authLogger.setup.error("\u274C SPFN_AUTH_ADMIN_ACCOUNTS must be an array");
|
|
@@ -8398,11 +8920,11 @@ function parseAdminAccounts() {
|
|
|
8398
8920
|
return accounts;
|
|
8399
8921
|
}
|
|
8400
8922
|
}
|
|
8401
|
-
const adminEmails =
|
|
8923
|
+
const adminEmails = env9.SPFN_AUTH_ADMIN_EMAILS;
|
|
8402
8924
|
if (adminEmails) {
|
|
8403
8925
|
const emails = adminEmails.split(",").map((s) => s.trim());
|
|
8404
|
-
const passwords = (
|
|
8405
|
-
const roles2 = (
|
|
8926
|
+
const passwords = (env9.SPFN_AUTH_ADMIN_PASSWORDS || "").split(",").map((s) => s.trim());
|
|
8927
|
+
const roles2 = (env9.SPFN_AUTH_ADMIN_ROLES || "").split(",").map((s) => s.trim());
|
|
8406
8928
|
if (passwords.length !== emails.length) {
|
|
8407
8929
|
authLogger.setup.error("\u274C SPFN_AUTH_ADMIN_EMAILS and SPFN_AUTH_ADMIN_PASSWORDS length mismatch");
|
|
8408
8930
|
return accounts;
|
|
@@ -8424,8 +8946,8 @@ function parseAdminAccounts() {
|
|
|
8424
8946
|
}
|
|
8425
8947
|
return accounts;
|
|
8426
8948
|
}
|
|
8427
|
-
const adminEmail =
|
|
8428
|
-
const adminPassword =
|
|
8949
|
+
const adminEmail = env9.SPFN_AUTH_ADMIN_EMAIL;
|
|
8950
|
+
const adminPassword = env9.SPFN_AUTH_ADMIN_PASSWORD;
|
|
8429
8951
|
if (adminEmail && adminPassword) {
|
|
8430
8952
|
accounts.push({
|
|
8431
8953
|
email: adminEmail,
|
|
@@ -8515,6 +9037,7 @@ export {
|
|
|
8515
9037
|
RolePermissionsRepository,
|
|
8516
9038
|
RolesRepository,
|
|
8517
9039
|
SOCIAL_PROVIDERS,
|
|
9040
|
+
SocialAccountsRepository,
|
|
8518
9041
|
TargetTypeSchema,
|
|
8519
9042
|
USER_STATUSES,
|
|
8520
9043
|
UserPermissionsRepository,
|
|
@@ -8530,16 +9053,19 @@ export {
|
|
|
8530
9053
|
mainAuthRouter as authRouter,
|
|
8531
9054
|
authSchema,
|
|
8532
9055
|
authenticate,
|
|
9056
|
+
buildOAuthErrorUrl,
|
|
8533
9057
|
cancelInvitation,
|
|
8534
9058
|
changePasswordService,
|
|
8535
9059
|
checkAccountExistsService,
|
|
8536
9060
|
configureAuth,
|
|
8537
9061
|
createAuthLifecycle,
|
|
8538
9062
|
createInvitation,
|
|
9063
|
+
createOAuthState,
|
|
8539
9064
|
createRole,
|
|
8540
9065
|
decodeToken,
|
|
8541
9066
|
deleteInvitation,
|
|
8542
9067
|
deleteRole,
|
|
9068
|
+
exchangeCodeForTokens,
|
|
8543
9069
|
expireOldInvitations,
|
|
8544
9070
|
generateClientToken,
|
|
8545
9071
|
generateKeyPair,
|
|
@@ -8550,6 +9076,10 @@ export {
|
|
|
8550
9076
|
getAuth,
|
|
8551
9077
|
getAuthConfig,
|
|
8552
9078
|
getAuthSessionService,
|
|
9079
|
+
getEnabledOAuthProviders,
|
|
9080
|
+
getGoogleAuthUrl,
|
|
9081
|
+
getGoogleOAuthConfig,
|
|
9082
|
+
getGoogleUserInfo,
|
|
8553
9083
|
getInvitationByToken,
|
|
8554
9084
|
getInvitationWithDetails,
|
|
8555
9085
|
getKeyId,
|
|
@@ -8574,13 +9104,18 @@ export {
|
|
|
8574
9104
|
hashPassword,
|
|
8575
9105
|
initializeAuth,
|
|
8576
9106
|
invitationsRepository,
|
|
9107
|
+
isGoogleOAuthEnabled,
|
|
9108
|
+
isOAuthProviderEnabled,
|
|
8577
9109
|
keysRepository,
|
|
8578
9110
|
listInvitations,
|
|
8579
9111
|
loginService,
|
|
8580
9112
|
logoutService,
|
|
9113
|
+
oauthCallbackService,
|
|
9114
|
+
oauthStartService,
|
|
8581
9115
|
parseDuration,
|
|
8582
9116
|
permissions,
|
|
8583
9117
|
permissionsRepository,
|
|
9118
|
+
refreshAccessToken,
|
|
8584
9119
|
registerPublicKeyService,
|
|
8585
9120
|
registerService,
|
|
8586
9121
|
removePermissionFromRole,
|
|
@@ -8600,6 +9135,7 @@ export {
|
|
|
8600
9135
|
setRolePermissions,
|
|
8601
9136
|
shouldRefreshSession,
|
|
8602
9137
|
shouldRotateKey,
|
|
9138
|
+
socialAccountsRepository,
|
|
8603
9139
|
unsealSession,
|
|
8604
9140
|
updateLastLoginService,
|
|
8605
9141
|
updateRole,
|
|
@@ -8621,6 +9157,7 @@ export {
|
|
|
8621
9157
|
verifyClientToken,
|
|
8622
9158
|
verifyCodeService,
|
|
8623
9159
|
verifyKeyFingerprint,
|
|
9160
|
+
verifyOAuthState,
|
|
8624
9161
|
verifyPassword,
|
|
8625
9162
|
verifyToken
|
|
8626
9163
|
};
|