vigor-roblox 1.0.3 → 1.0.4
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/index.js +122 -2
- package/dist/index.mjs +122 -2
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -1838,10 +1838,85 @@ function partition(arr, pred) {
|
|
|
1838
1838
|
return { pass, fail };
|
|
1839
1839
|
}
|
|
1840
1840
|
// ----------------------------------------------------------------
|
|
1841
|
+
// CSRF Token Manager
|
|
1842
|
+
// ----------------------------------------------------------------
|
|
1843
|
+
class CsrfTokenManager {
|
|
1844
|
+
// cookie → token 매핑 (쿠키별로 독립 토큰 관리)
|
|
1845
|
+
tokenMap = new Map();
|
|
1846
|
+
// 쿠키별 진행 중인 갱신 Promise (중복 갱신 방지)
|
|
1847
|
+
pendingMap = new Map();
|
|
1848
|
+
/**
|
|
1849
|
+
* 저장된 토큰을 반환합니다. 없으면 null.
|
|
1850
|
+
*/
|
|
1851
|
+
get(cookie) {
|
|
1852
|
+
return this.tokenMap.get(cookie) ?? null;
|
|
1853
|
+
}
|
|
1854
|
+
/**
|
|
1855
|
+
* 토큰을 수동으로 저장합니다.
|
|
1856
|
+
* (403 응답 헤더의 x-csrf-token 값을 받아서 저장할 때 사용)
|
|
1857
|
+
*/
|
|
1858
|
+
set(cookie, token) {
|
|
1859
|
+
this.tokenMap.set(cookie, token);
|
|
1860
|
+
}
|
|
1861
|
+
/**
|
|
1862
|
+
* 토큰을 무효화합니다.
|
|
1863
|
+
* (재시도 전 갱신이 필요할 때 호출)
|
|
1864
|
+
*/
|
|
1865
|
+
invalidate(cookie) {
|
|
1866
|
+
this.tokenMap.delete(cookie);
|
|
1867
|
+
}
|
|
1868
|
+
/**
|
|
1869
|
+
* Roblox의 CSRF 토큰 갱신 방식:
|
|
1870
|
+
* POST /v1/logout 등 아무 인증 엔드포인트에 빈 body로 요청하면
|
|
1871
|
+
* 403 응답과 함께 x-csrf-token 헤더로 새 토큰을 내려줍니다.
|
|
1872
|
+
*
|
|
1873
|
+
* 동일 쿠키에 대해 동시에 여러 갱신 요청이 오면
|
|
1874
|
+
* 하나만 실제로 요청하고 나머지는 그 결과를 공유합니다.
|
|
1875
|
+
*/
|
|
1876
|
+
async refresh(cookie) {
|
|
1877
|
+
const existing = this.pendingMap.get(cookie);
|
|
1878
|
+
if (existing)
|
|
1879
|
+
return existing;
|
|
1880
|
+
const pending = (async () => {
|
|
1881
|
+
try {
|
|
1882
|
+
// Roblox는 POST에 body가 없어도 403 + x-csrf-token을 내려줌
|
|
1883
|
+
const response = await fetch('https://auth.roblox.com/v2/logout', {
|
|
1884
|
+
method: 'POST',
|
|
1885
|
+
headers: {
|
|
1886
|
+
'Cookie': `.ROBLOSECURITY=${cookie}`,
|
|
1887
|
+
'User-Agent': 'Roblox/WinInet',
|
|
1888
|
+
'Content-Length': '0',
|
|
1889
|
+
},
|
|
1890
|
+
});
|
|
1891
|
+
const token = response.headers.get('x-csrf-token');
|
|
1892
|
+
if (!token)
|
|
1893
|
+
throw new Error('CSRF token not found in response headers');
|
|
1894
|
+
this.tokenMap.set(cookie, token);
|
|
1895
|
+
return token;
|
|
1896
|
+
}
|
|
1897
|
+
finally {
|
|
1898
|
+
this.pendingMap.delete(cookie);
|
|
1899
|
+
}
|
|
1900
|
+
})();
|
|
1901
|
+
this.pendingMap.set(cookie, pending);
|
|
1902
|
+
return pending;
|
|
1903
|
+
}
|
|
1904
|
+
/**
|
|
1905
|
+
* 저장된 토큰을 반환하되, 없으면 자동으로 갱신 후 반환합니다.
|
|
1906
|
+
*/
|
|
1907
|
+
async getOrRefresh(cookie) {
|
|
1908
|
+
const cached = this.get(cookie);
|
|
1909
|
+
if (cached)
|
|
1910
|
+
return cached;
|
|
1911
|
+
return this.refresh(cookie);
|
|
1912
|
+
}
|
|
1913
|
+
}
|
|
1914
|
+
// ----------------------------------------------------------------
|
|
1841
1915
|
// Factory
|
|
1842
1916
|
// ----------------------------------------------------------------
|
|
1843
1917
|
function createRobloxApi({ cache, cookies: cookiesList, ipgeolocationKey, }) {
|
|
1844
1918
|
const cookiePool = cookiesList.map(cookie => ({ cookie, lastUsed: 0 }));
|
|
1919
|
+
const csrfManager = new CsrfTokenManager();
|
|
1845
1920
|
function pickCookie() {
|
|
1846
1921
|
const entry = cookiePool.reduce((a, b) => a.lastUsed < b.lastUsed ? a : b);
|
|
1847
1922
|
entry.lastUsed = Date.now();
|
|
@@ -1854,6 +1929,47 @@ function createRobloxApi({ cache, cookies: cookiesList, ipgeolocationKey, }) {
|
|
|
1854
1929
|
});
|
|
1855
1930
|
}
|
|
1856
1931
|
const dataInterceptor = pickKey('data');
|
|
1932
|
+
// ----------------------------------------------------------------
|
|
1933
|
+
// CSRF 인터셉터 팩토리
|
|
1934
|
+
// ----------------------------------------------------------------
|
|
1935
|
+
// cookie를 인자로 받아 해당 쿠키 전용 CSRF 인터셉터를 생성합니다.
|
|
1936
|
+
// 동작 방식:
|
|
1937
|
+
// before → 캐시된 토큰이 있으면 X-CSRF-Token 헤더에 주입
|
|
1938
|
+
// onError → 403 응답이고 헤더에 x-csrf-token이 있으면
|
|
1939
|
+
// 토큰을 갱신하고 요청을 재시작(restart)
|
|
1940
|
+
function makeCsrfInterceptor(getCookie) {
|
|
1941
|
+
return vigor.builder.fetch.interceptors()
|
|
1942
|
+
.before(async (ctx, api) => {
|
|
1943
|
+
const cookie = getCookie();
|
|
1944
|
+
const token = await csrfManager.getOrRefresh(cookie);
|
|
1945
|
+
api.setHeaders({
|
|
1946
|
+
...ctx.options.headers,
|
|
1947
|
+
'X-CSRF-Token': token,
|
|
1948
|
+
});
|
|
1949
|
+
})
|
|
1950
|
+
.onError(async (ctx, api) => {
|
|
1951
|
+
// VigorFetchError + FETCH_FAILED + status 403 여부 확인
|
|
1952
|
+
const cause = ctx.error;
|
|
1953
|
+
if (cause instanceof VigorFetchError &&
|
|
1954
|
+
cause.code === 'FETCH_FAILED' &&
|
|
1955
|
+
cause.data?.status === 403) {
|
|
1956
|
+
const response = cause.data.response;
|
|
1957
|
+
const newToken = response.headers.get('x-csrf-token');
|
|
1958
|
+
const cookie = getCookie();
|
|
1959
|
+
if (newToken) {
|
|
1960
|
+
// 새 토큰 저장 후 재시도
|
|
1961
|
+
csrfManager.set(cookie, newToken);
|
|
1962
|
+
api.restart?.();
|
|
1963
|
+
}
|
|
1964
|
+
else {
|
|
1965
|
+
// 헤더에 토큰이 없으면 기존 토큰 무효화 후 강제 갱신 후 재시도
|
|
1966
|
+
csrfManager.invalidate(cookie);
|
|
1967
|
+
await csrfManager.refresh(cookie);
|
|
1968
|
+
api.restart?.();
|
|
1969
|
+
}
|
|
1970
|
+
}
|
|
1971
|
+
});
|
|
1972
|
+
}
|
|
1857
1973
|
const cookieInterceptor = vigor.builder.fetch.interceptors()
|
|
1858
1974
|
.before((ctx, api) => {
|
|
1859
1975
|
api.setHeaders({
|
|
@@ -1868,6 +1984,9 @@ function createRobloxApi({ cache, cookies: cookiesList, ipgeolocationKey, }) {
|
|
|
1868
1984
|
'User-Agent': 'Roblox/WinInet',
|
|
1869
1985
|
});
|
|
1870
1986
|
});
|
|
1987
|
+
// CSRF가 필요한 POST 엔드포인트에 붙일 공용 인터셉터
|
|
1988
|
+
// pickCookie()로 현재 선택된 쿠키를 기준으로 토큰 관리
|
|
1989
|
+
const csrfInterceptor = makeCsrfInterceptor(pickCookie);
|
|
1871
1990
|
const usersApi = vigor.fetch('https://users.roblox.com/v1')
|
|
1872
1991
|
.interceptors(cookieInterceptor)
|
|
1873
1992
|
.interceptors(winInetInterceptor)
|
|
@@ -1904,11 +2023,12 @@ function createRobloxApi({ cache, cookies: cookiesList, ipgeolocationKey, }) {
|
|
|
1904
2023
|
.retryConfig(c => c
|
|
1905
2024
|
.settings(s => s.attempt(4))
|
|
1906
2025
|
.algorithms(a => a.backoff().initial(500).multiplier(2)));
|
|
1907
|
-
// friends.roblox.com —
|
|
1908
|
-
//
|
|
2026
|
+
// friends.roblox.com — CSRF 인터셉터 포함
|
|
2027
|
+
// sendFriendRequest / unfriend 는 POST라 CSRF 토큰 필요
|
|
1909
2028
|
const friendsApi = vigor.fetch('https://friends.roblox.com/v1')
|
|
1910
2029
|
.interceptors(cookieInterceptor)
|
|
1911
2030
|
.interceptors(winInetInterceptor)
|
|
2031
|
+
.interceptors(csrfInterceptor) // ← CSRF 추가
|
|
1912
2032
|
.retryConfig(c => c
|
|
1913
2033
|
.settings(s => s.attempt(5))
|
|
1914
2034
|
.algorithms(a => a.backoff().initial(500).multiplier(2)));
|
package/dist/index.mjs
CHANGED
|
@@ -1836,10 +1836,85 @@ function partition(arr, pred) {
|
|
|
1836
1836
|
return { pass, fail };
|
|
1837
1837
|
}
|
|
1838
1838
|
// ----------------------------------------------------------------
|
|
1839
|
+
// CSRF Token Manager
|
|
1840
|
+
// ----------------------------------------------------------------
|
|
1841
|
+
class CsrfTokenManager {
|
|
1842
|
+
// cookie → token 매핑 (쿠키별로 독립 토큰 관리)
|
|
1843
|
+
tokenMap = new Map();
|
|
1844
|
+
// 쿠키별 진행 중인 갱신 Promise (중복 갱신 방지)
|
|
1845
|
+
pendingMap = new Map();
|
|
1846
|
+
/**
|
|
1847
|
+
* 저장된 토큰을 반환합니다. 없으면 null.
|
|
1848
|
+
*/
|
|
1849
|
+
get(cookie) {
|
|
1850
|
+
return this.tokenMap.get(cookie) ?? null;
|
|
1851
|
+
}
|
|
1852
|
+
/**
|
|
1853
|
+
* 토큰을 수동으로 저장합니다.
|
|
1854
|
+
* (403 응답 헤더의 x-csrf-token 값을 받아서 저장할 때 사용)
|
|
1855
|
+
*/
|
|
1856
|
+
set(cookie, token) {
|
|
1857
|
+
this.tokenMap.set(cookie, token);
|
|
1858
|
+
}
|
|
1859
|
+
/**
|
|
1860
|
+
* 토큰을 무효화합니다.
|
|
1861
|
+
* (재시도 전 갱신이 필요할 때 호출)
|
|
1862
|
+
*/
|
|
1863
|
+
invalidate(cookie) {
|
|
1864
|
+
this.tokenMap.delete(cookie);
|
|
1865
|
+
}
|
|
1866
|
+
/**
|
|
1867
|
+
* Roblox의 CSRF 토큰 갱신 방식:
|
|
1868
|
+
* POST /v1/logout 등 아무 인증 엔드포인트에 빈 body로 요청하면
|
|
1869
|
+
* 403 응답과 함께 x-csrf-token 헤더로 새 토큰을 내려줍니다.
|
|
1870
|
+
*
|
|
1871
|
+
* 동일 쿠키에 대해 동시에 여러 갱신 요청이 오면
|
|
1872
|
+
* 하나만 실제로 요청하고 나머지는 그 결과를 공유합니다.
|
|
1873
|
+
*/
|
|
1874
|
+
async refresh(cookie) {
|
|
1875
|
+
const existing = this.pendingMap.get(cookie);
|
|
1876
|
+
if (existing)
|
|
1877
|
+
return existing;
|
|
1878
|
+
const pending = (async () => {
|
|
1879
|
+
try {
|
|
1880
|
+
// Roblox는 POST에 body가 없어도 403 + x-csrf-token을 내려줌
|
|
1881
|
+
const response = await fetch('https://auth.roblox.com/v2/logout', {
|
|
1882
|
+
method: 'POST',
|
|
1883
|
+
headers: {
|
|
1884
|
+
'Cookie': `.ROBLOSECURITY=${cookie}`,
|
|
1885
|
+
'User-Agent': 'Roblox/WinInet',
|
|
1886
|
+
'Content-Length': '0',
|
|
1887
|
+
},
|
|
1888
|
+
});
|
|
1889
|
+
const token = response.headers.get('x-csrf-token');
|
|
1890
|
+
if (!token)
|
|
1891
|
+
throw new Error('CSRF token not found in response headers');
|
|
1892
|
+
this.tokenMap.set(cookie, token);
|
|
1893
|
+
return token;
|
|
1894
|
+
}
|
|
1895
|
+
finally {
|
|
1896
|
+
this.pendingMap.delete(cookie);
|
|
1897
|
+
}
|
|
1898
|
+
})();
|
|
1899
|
+
this.pendingMap.set(cookie, pending);
|
|
1900
|
+
return pending;
|
|
1901
|
+
}
|
|
1902
|
+
/**
|
|
1903
|
+
* 저장된 토큰을 반환하되, 없으면 자동으로 갱신 후 반환합니다.
|
|
1904
|
+
*/
|
|
1905
|
+
async getOrRefresh(cookie) {
|
|
1906
|
+
const cached = this.get(cookie);
|
|
1907
|
+
if (cached)
|
|
1908
|
+
return cached;
|
|
1909
|
+
return this.refresh(cookie);
|
|
1910
|
+
}
|
|
1911
|
+
}
|
|
1912
|
+
// ----------------------------------------------------------------
|
|
1839
1913
|
// Factory
|
|
1840
1914
|
// ----------------------------------------------------------------
|
|
1841
1915
|
function createRobloxApi({ cache, cookies: cookiesList, ipgeolocationKey, }) {
|
|
1842
1916
|
const cookiePool = cookiesList.map(cookie => ({ cookie, lastUsed: 0 }));
|
|
1917
|
+
const csrfManager = new CsrfTokenManager();
|
|
1843
1918
|
function pickCookie() {
|
|
1844
1919
|
const entry = cookiePool.reduce((a, b) => a.lastUsed < b.lastUsed ? a : b);
|
|
1845
1920
|
entry.lastUsed = Date.now();
|
|
@@ -1852,6 +1927,47 @@ function createRobloxApi({ cache, cookies: cookiesList, ipgeolocationKey, }) {
|
|
|
1852
1927
|
});
|
|
1853
1928
|
}
|
|
1854
1929
|
const dataInterceptor = pickKey('data');
|
|
1930
|
+
// ----------------------------------------------------------------
|
|
1931
|
+
// CSRF 인터셉터 팩토리
|
|
1932
|
+
// ----------------------------------------------------------------
|
|
1933
|
+
// cookie를 인자로 받아 해당 쿠키 전용 CSRF 인터셉터를 생성합니다.
|
|
1934
|
+
// 동작 방식:
|
|
1935
|
+
// before → 캐시된 토큰이 있으면 X-CSRF-Token 헤더에 주입
|
|
1936
|
+
// onError → 403 응답이고 헤더에 x-csrf-token이 있으면
|
|
1937
|
+
// 토큰을 갱신하고 요청을 재시작(restart)
|
|
1938
|
+
function makeCsrfInterceptor(getCookie) {
|
|
1939
|
+
return vigor.builder.fetch.interceptors()
|
|
1940
|
+
.before(async (ctx, api) => {
|
|
1941
|
+
const cookie = getCookie();
|
|
1942
|
+
const token = await csrfManager.getOrRefresh(cookie);
|
|
1943
|
+
api.setHeaders({
|
|
1944
|
+
...ctx.options.headers,
|
|
1945
|
+
'X-CSRF-Token': token,
|
|
1946
|
+
});
|
|
1947
|
+
})
|
|
1948
|
+
.onError(async (ctx, api) => {
|
|
1949
|
+
// VigorFetchError + FETCH_FAILED + status 403 여부 확인
|
|
1950
|
+
const cause = ctx.error;
|
|
1951
|
+
if (cause instanceof VigorFetchError &&
|
|
1952
|
+
cause.code === 'FETCH_FAILED' &&
|
|
1953
|
+
cause.data?.status === 403) {
|
|
1954
|
+
const response = cause.data.response;
|
|
1955
|
+
const newToken = response.headers.get('x-csrf-token');
|
|
1956
|
+
const cookie = getCookie();
|
|
1957
|
+
if (newToken) {
|
|
1958
|
+
// 새 토큰 저장 후 재시도
|
|
1959
|
+
csrfManager.set(cookie, newToken);
|
|
1960
|
+
api.restart?.();
|
|
1961
|
+
}
|
|
1962
|
+
else {
|
|
1963
|
+
// 헤더에 토큰이 없으면 기존 토큰 무효화 후 강제 갱신 후 재시도
|
|
1964
|
+
csrfManager.invalidate(cookie);
|
|
1965
|
+
await csrfManager.refresh(cookie);
|
|
1966
|
+
api.restart?.();
|
|
1967
|
+
}
|
|
1968
|
+
}
|
|
1969
|
+
});
|
|
1970
|
+
}
|
|
1855
1971
|
const cookieInterceptor = vigor.builder.fetch.interceptors()
|
|
1856
1972
|
.before((ctx, api) => {
|
|
1857
1973
|
api.setHeaders({
|
|
@@ -1866,6 +1982,9 @@ function createRobloxApi({ cache, cookies: cookiesList, ipgeolocationKey, }) {
|
|
|
1866
1982
|
'User-Agent': 'Roblox/WinInet',
|
|
1867
1983
|
});
|
|
1868
1984
|
});
|
|
1985
|
+
// CSRF가 필요한 POST 엔드포인트에 붙일 공용 인터셉터
|
|
1986
|
+
// pickCookie()로 현재 선택된 쿠키를 기준으로 토큰 관리
|
|
1987
|
+
const csrfInterceptor = makeCsrfInterceptor(pickCookie);
|
|
1869
1988
|
const usersApi = vigor.fetch('https://users.roblox.com/v1')
|
|
1870
1989
|
.interceptors(cookieInterceptor)
|
|
1871
1990
|
.interceptors(winInetInterceptor)
|
|
@@ -1902,11 +2021,12 @@ function createRobloxApi({ cache, cookies: cookiesList, ipgeolocationKey, }) {
|
|
|
1902
2021
|
.retryConfig(c => c
|
|
1903
2022
|
.settings(s => s.attempt(4))
|
|
1904
2023
|
.algorithms(a => a.backoff().initial(500).multiplier(2)));
|
|
1905
|
-
// friends.roblox.com —
|
|
1906
|
-
//
|
|
2024
|
+
// friends.roblox.com — CSRF 인터셉터 포함
|
|
2025
|
+
// sendFriendRequest / unfriend 는 POST라 CSRF 토큰 필요
|
|
1907
2026
|
const friendsApi = vigor.fetch('https://friends.roblox.com/v1')
|
|
1908
2027
|
.interceptors(cookieInterceptor)
|
|
1909
2028
|
.interceptors(winInetInterceptor)
|
|
2029
|
+
.interceptors(csrfInterceptor) // ← CSRF 추가
|
|
1910
2030
|
.retryConfig(c => c
|
|
1911
2031
|
.settings(s => s.attempt(5))
|
|
1912
2032
|
.algorithms(a => a.backoff().initial(500).multiplier(2)));
|