From 90b2e1383a157ca072bd3fec0ed31026cf881060 Mon Sep 17 00:00:00 2001 From: Sh031224 <1cktmdgh2@gmail.com> Date: Wed, 25 Mar 2026 14:14:32 +0900 Subject: [PATCH 1/4] =?UTF-8?q?feat(wds-mcp):=20refresh=20token=20?= =?UTF-8?q?=EC=A7=80=EC=9B=90=20=EB=B0=8F=20Firestore=20=EC=98=81=EC=86=8D?= =?UTF-8?q?=20=EC=A0=80=EC=9E=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Google OAuth refresh token을 활용하여 Firebase ID token 만료 시 자동 갱신 지원. refresh token은 Firestore에 저장하여 서버 재시작 시에도 유지. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/wds-mcp/src/auth.ts | 124 +++++++++++++++++++++++++++++++++-- packages/wds-mcp/src/http.ts | 1 + 2 files changed, 120 insertions(+), 5 deletions(-) diff --git a/packages/wds-mcp/src/auth.ts b/packages/wds-mcp/src/auth.ts index 8486b3b84..43979bc55 100644 --- a/packages/wds-mcp/src/auth.ts +++ b/packages/wds-mcp/src/auth.ts @@ -2,6 +2,7 @@ import crypto from 'node:crypto'; import { cert, initializeApp } from 'firebase-admin/app'; import { getAuth } from 'firebase-admin/auth'; +import { getFirestore } from 'firebase-admin/firestore'; import type { AuthorizationParams, @@ -32,6 +33,8 @@ const firebaseApp = initializeApp( ); const firebaseAuth = getAuth(firebaseApp); +const firestore = getFirestore(firebaseApp, 'montage-storage'); +const refreshTokensCollection = firestore.collection('refreshTokens'); interface PendingAuth { clientId: string; @@ -81,6 +84,26 @@ const registeredClients = new TtlMap(); const pendingAuths = new TtlMap(); const authCodes = new TtlMap(); +const refreshTokenStore = { + async set(mcpToken: string, googleToken: string) { + await refreshTokensCollection.doc(mcpToken).set({ + googleRefreshToken: googleToken, + }); + }, + + async get(mcpToken: string): Promise { + const doc = await refreshTokensCollection.doc(mcpToken).get(); + + if (!doc.exists) return undefined; + + return (doc.data() as { googleRefreshToken: string }).googleRefreshToken; + }, + + async delete(mcpToken: string) { + await refreshTokensCollection.doc(mcpToken).delete(); + }, +}; + export const getServerUrl = () => process.env.MCP_SERVER_URL || 'http://localhost:3000'; @@ -179,6 +202,7 @@ export const createOAuthProvider = (): OAuthServerProvider => ({ access_token: string; id_token: string; expires_in: number; + refresh_token?: string; }; // Exchange Google ID token for Firebase ID token via REST API @@ -219,17 +243,107 @@ export const createOAuthProvider = (): OAuthServerProvider => ({ authCodes.delete(authorizationCode); - // Return the Firebase ID token — verifiable statelessly via firebaseAuth.verifyIdToken() - return { + // Store Google refresh token for later token renewal + const tokens: OAuthTokens = { access_token: firebaseTokens.idToken, token_type: 'bearer', expires_in: Number(firebaseTokens.expiresIn), scope: 'openid email profile', - } as OAuthTokens; + }; + + if (googleTokens.refresh_token) { + const mcpRefreshToken = crypto.randomUUID(); + await refreshTokenStore.set(mcpRefreshToken, googleTokens.refresh_token); + tokens.refresh_token = mcpRefreshToken; + } + + return tokens; }, - async exchangeRefreshToken() { - throw new Error('Refresh tokens not supported'); + async exchangeRefreshToken( + _client: OAuthClientInformationFull, + refreshToken: string, + ) { + const googleRefreshToken = await refreshTokenStore.get(refreshToken); + + if (!googleRefreshToken) { + throw new Error('Invalid or expired refresh token'); + } + + // Use Google refresh token to get new tokens + const tokenResponse = await fetch('https://oauth2.googleapis.com/token', { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ + refresh_token: googleRefreshToken, + client_id: GOOGLE_CLIENT_ID, + client_secret: GOOGLE_CLIENT_SECRET, + grant_type: 'refresh_token', + }), + signal: AbortSignal.timeout(FETCH_TIMEOUT_MS), + }).catch((error) => { + if (error instanceof DOMException && error.name === 'TimeoutError') { + throw new Error('Google token refresh timed out'); + } + throw error; + }); + + if (!tokenResponse.ok) { + // Google refresh token may have been revoked + await refreshTokenStore.delete(refreshToken); + const error = await tokenResponse.text(); + throw new Error(`Failed to refresh Google token: ${error}`); + } + + const googleTokens = (await tokenResponse.json()) as { + access_token: string; + id_token: string; + expires_in: number; + }; + + // Exchange new Google ID token for Firebase ID token + const firebaseResponse = await fetch( + `https://identitytoolkit.googleapis.com/v1/accounts:signInWithIdp?key=${FIREBASE_API_KEY}`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + postBody: `id_token=${googleTokens.id_token}&providerId=google.com`, + requestUri: getServerUrl(), + returnIdToken: true, + returnSecureToken: true, + }), + signal: AbortSignal.timeout(FETCH_TIMEOUT_MS), + }, + ).catch((error) => { + if (error instanceof DOMException && error.name === 'TimeoutError') { + throw new Error('Firebase sign-in timed out'); + } + throw error; + }); + + if (!firebaseResponse.ok) { + const error = await firebaseResponse.text(); + throw new Error(`Firebase sign-in failed: ${error}`); + } + + const firebaseTokens = (await firebaseResponse.json()) as { + idToken: string; + expiresIn: string; + email?: string; + }; + + if (!firebaseTokens.email?.endsWith(`@${ALLOWED_DOMAIN}`)) { + throw new Error(`Access restricted to @${ALLOWED_DOMAIN} accounts`); + } + + return { + access_token: firebaseTokens.idToken, + token_type: 'bearer', + expires_in: Number(firebaseTokens.expiresIn), + scope: 'openid email profile', + refresh_token: refreshToken, + } as OAuthTokens; }, async verifyAccessToken(token: string): Promise { diff --git a/packages/wds-mcp/src/http.ts b/packages/wds-mcp/src/http.ts index a969cb817..88aa62848 100644 --- a/packages/wds-mcp/src/http.ts +++ b/packages/wds-mcp/src/http.ts @@ -14,6 +14,7 @@ const PORT = parseInt(process.env.PORT ?? '3000', 10); const app = express(); +app.set('trust proxy', 2); app.use(express.json()); const provider = createOAuthProvider(); From b7d4f9402d3fa6c42d00f42c0370eb3fb621bcd7 Mon Sep 17 00:00:00 2001 From: Sh031224 <1cktmdgh2@gmail.com> Date: Wed, 25 Mar 2026 14:35:54 +0900 Subject: [PATCH 2/4] =?UTF-8?q?fix(wds-mcp):=20google=20refresh=20token=20?= =?UTF-8?q?rotation=20=EB=8C=80=EC=9D=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit google이 refresh 응답에서 새 refresh_token을 반환할 경우 Firestore 매핑을 업데이트 Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/wds-mcp/src/auth.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/wds-mcp/src/auth.ts b/packages/wds-mcp/src/auth.ts index 43979bc55..0f976d5bb 100644 --- a/packages/wds-mcp/src/auth.ts +++ b/packages/wds-mcp/src/auth.ts @@ -299,8 +299,14 @@ export const createOAuthProvider = (): OAuthServerProvider => ({ access_token: string; id_token: string; expires_in: number; + refresh_token?: string; }; + // Google may rotate refresh tokens + if (googleTokens.refresh_token) { + await refreshTokenStore.set(refreshToken, googleTokens.refresh_token); + } + // Exchange new Google ID token for Firebase ID token const firebaseResponse = await fetch( `https://identitytoolkit.googleapis.com/v1/accounts:signInWithIdp?key=${FIREBASE_API_KEY}`, From 461f74c0d4e3d52cd07094073bfceefdfef414a6 Mon Sep 17 00:00:00 2001 From: Sh031224 <1cktmdgh2@gmail.com> Date: Wed, 25 Mar 2026 14:45:01 +0900 Subject: [PATCH 3/4] =?UTF-8?q?fix(wds-mcp):=20=EC=9D=BC=EC=8B=9C=EC=A0=81?= =?UTF-8?q?=20=EC=98=A4=EB=A5=98=20=EC=8B=9C=20refresh=20token=20=EC=82=AD?= =?UTF-8?q?=EC=A0=9C=20=EB=B0=A9=EC=A7=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit google 서버 장애(5xx) 등 일시적 오류에서는 refresh token을 유지하고, token revoke/invalid(400/401)인 경우에만 삭제 Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/wds-mcp/src/auth.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/wds-mcp/src/auth.ts b/packages/wds-mcp/src/auth.ts index 0f976d5bb..32cab6631 100644 --- a/packages/wds-mcp/src/auth.ts +++ b/packages/wds-mcp/src/auth.ts @@ -289,9 +289,13 @@ export const createOAuthProvider = (): OAuthServerProvider => ({ }); if (!tokenResponse.ok) { - // Google refresh token may have been revoked - await refreshTokenStore.delete(refreshToken); const error = await tokenResponse.text(); + + // Only delete on permanent failures (token revoked/invalid) + if (tokenResponse.status === 400 || tokenResponse.status === 401) { + await refreshTokenStore.delete(refreshToken); + } + throw new Error(`Failed to refresh Google token: ${error}`); } From dc0001d045d2d64031bb20da3787154477e8e05b Mon Sep 17 00:00:00 2001 From: Sh031224 <1cktmdgh2@gmail.com> Date: Wed, 25 Mar 2026 16:20:15 +0900 Subject: [PATCH 4/4] =?UTF-8?q?fix(wds-mcp):=20refresh=20token=EC=9D=84=20?= =?UTF-8?q?=EB=B0=9C=EA=B8=89=20client=EC=97=90=20=EB=B0=94=EC=9D=B8?= =?UTF-8?q?=EB=94=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 다른 OAuth client가 refresh token을 재사용하지 못하도록 clientId를 함께 저장하고 갱신 시 검증 Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/wds-mcp/src/auth.ts | 35 +++++++++++++++++++++++++++-------- 1 file changed, 27 insertions(+), 8 deletions(-) diff --git a/packages/wds-mcp/src/auth.ts b/packages/wds-mcp/src/auth.ts index 32cab6631..5dc33b563 100644 --- a/packages/wds-mcp/src/auth.ts +++ b/packages/wds-mcp/src/auth.ts @@ -84,19 +84,29 @@ const registeredClients = new TtlMap(); const pendingAuths = new TtlMap(); const authCodes = new TtlMap(); +interface StoredRefreshToken { + clientId: string; + googleRefreshToken: string; +} + const refreshTokenStore = { - async set(mcpToken: string, googleToken: string) { + async set(mcpToken: string, value: StoredRefreshToken) { await refreshTokensCollection.doc(mcpToken).set({ - googleRefreshToken: googleToken, + clientId: value.clientId, + googleRefreshToken: value.googleRefreshToken, }); }, - async get(mcpToken: string): Promise { + async get(mcpToken: string, clientId: string): Promise { const doc = await refreshTokensCollection.doc(mcpToken).get(); if (!doc.exists) return undefined; - return (doc.data() as { googleRefreshToken: string }).googleRefreshToken; + const data = doc.data() as StoredRefreshToken; + + if (data.clientId !== clientId) return undefined; + + return data.googleRefreshToken; }, async delete(mcpToken: string) { @@ -253,7 +263,10 @@ export const createOAuthProvider = (): OAuthServerProvider => ({ if (googleTokens.refresh_token) { const mcpRefreshToken = crypto.randomUUID(); - await refreshTokenStore.set(mcpRefreshToken, googleTokens.refresh_token); + await refreshTokenStore.set(mcpRefreshToken, { + clientId: client.client_id, + googleRefreshToken: googleTokens.refresh_token, + }); tokens.refresh_token = mcpRefreshToken; } @@ -261,10 +274,13 @@ export const createOAuthProvider = (): OAuthServerProvider => ({ }, async exchangeRefreshToken( - _client: OAuthClientInformationFull, + client: OAuthClientInformationFull, refreshToken: string, ) { - const googleRefreshToken = await refreshTokenStore.get(refreshToken); + const googleRefreshToken = await refreshTokenStore.get( + refreshToken, + client.client_id, + ); if (!googleRefreshToken) { throw new Error('Invalid or expired refresh token'); @@ -308,7 +324,10 @@ export const createOAuthProvider = (): OAuthServerProvider => ({ // Google may rotate refresh tokens if (googleTokens.refresh_token) { - await refreshTokenStore.set(refreshToken, googleTokens.refresh_token); + await refreshTokenStore.set(refreshToken, { + clientId: client.client_id, + googleRefreshToken: googleTokens.refresh_token, + }); } // Exchange new Google ID token for Firebase ID token