From d85c6eb2795b9af67b2ae5f1ae9db0b9efdf21ea Mon Sep 17 00:00:00 2001 From: hided62 Date: Tue, 11 Aug 2026 14:52:17 +0000 Subject: [PATCH] fix: keep profile entry tokens out of URLs --- app/game-frontend/e2e/session-auth.spec.ts | 50 +++++++++++++++++ app/game-frontend/src/stores/session.ts | 18 ++++++ .../e2e/lobby-game-auth.spec.ts | 44 ++++++++++++--- app/gateway-frontend/src/views/LobbyView.vue | 23 +++++++- packages/common/package.json | 4 ++ .../common/src/auth/gameSessionTransfer.ts | 56 +++++++++++++++++++ .../common/test/game-session-transfer.test.ts | 54 ++++++++++++++++++ packages/common/tsdown.config.ts | 1 + 8 files changed, 239 insertions(+), 11 deletions(-) create mode 100644 packages/common/src/auth/gameSessionTransfer.ts create mode 100644 packages/common/test/game-session-transfer.test.ts diff --git a/app/game-frontend/e2e/session-auth.spec.ts b/app/game-frontend/e2e/session-auth.spec.ts index 84a6188f..bb86a04d 100644 --- a/app/game-frontend/e2e/session-auth.spec.ts +++ b/app/game-frontend/e2e/session-auth.spec.ts @@ -34,6 +34,56 @@ const seedGameStorage = async (page: Page, gameToken: string): Promise => ); }; +test('consumes a same-tab gateway transfer from a clean profile URL', async ({ page }) => { + await page.addInitScript((profile) => { + window.sessionStorage.setItem( + 'sammo-pending-game-session', + JSON.stringify({ profile, gatewayToken: 'gateway-transfer-token' }) + ); + }, gameProfile); + const exchangedInputs: unknown[] = []; + await page.route(gameTrpcRoute, async (route) => { + const operations = operationNames(route); + const requestBody = route.request().postDataJSON() as + { json?: unknown; 0?: { json?: unknown; gatewayToken?: string } } | undefined; + const results = operations.map((operation) => { + if (operation === 'auth.exchangeGatewayToken') { + exchangedInputs.push(requestBody?.json ?? requestBody?.[0]?.json ?? requestBody?.[0]); + return response({ + accessToken: 'ga_transferred', + profile: gameProfile, + expiresAt: '2026-08-11T01:00:00Z', + }); + } + if (operation === 'auth.status') { + expect(route.request().headers().authorization).toBe('Bearer ga_transferred'); + return response({ userId: 'transfer-user' }); + } + if (operation === 'lobby.info') { + return response({ myGeneral: null }); + } + if (operation === 'join.getConfig') { + return response({}); + } + return publicResponse(operation); + }); + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify(results), + }); + }); + + await page.goto('select-general'); + + await expect(page).toHaveURL(new RegExp(`${gameBasePath}/select-general$`)); + await expect(page.locator('.page-title')).toContainText('장 수 선 택'); + expect(exchangedInputs).toEqual([{ gatewayToken: 'gateway-transfer-token' }]); + expect(await page.evaluate(() => window.sessionStorage.getItem('sammo-pending-game-session'))).toBeNull(); + expect(await page.evaluate(() => window.localStorage.getItem('sammo-game-profile'))).toBe(gameProfile); + expect(await page.evaluate(() => window.localStorage.getItem('sammo-game-token'))).toBe('ga_transferred'); +}); + test('removes an invalid ga_ token and redirects an authenticated route to public', async ({ page }) => { await seedGameStorage(page, 'ga_invalid'); let gatewayRequests = 0; diff --git a/app/game-frontend/src/stores/session.ts b/app/game-frontend/src/stores/session.ts index 86510c83..f792ac64 100644 --- a/app/game-frontend/src/stores/session.ts +++ b/app/game-frontend/src/stores/session.ts @@ -1,4 +1,5 @@ import { defineStore } from 'pinia'; +import { takeGameSessionTransfer, type GameSessionTransfer } from '@sammo-ts/common/auth/gameSessionTransfer'; import { gatewayTrpc } from '../utils/gatewayTrpc'; import { trpc as gameTrpc } from '../utils/trpc'; @@ -57,6 +58,17 @@ const readQueryParam = (key: string): string | null => { return value; }; +const readTransferredGameSession = (): GameSessionTransfer | null => { + if (typeof window === 'undefined') { + return null; + } + try { + return takeGameSessionTransfer(window.sessionStorage); + } catch { + return null; + } +}; + const isAccessToken = (token: string | null): boolean => { if (!token) { return false; @@ -194,6 +206,12 @@ export const useSessionStore = defineStore('session', { this.setGameToken(gatewayTokenFromQuery); } + const transferredGameSession = readTransferredGameSession(); + if (transferredGameSession) { + this.setProfile(transferredGameSession.profile); + this.setGameToken(transferredGameSession.gatewayToken); + } + const storedToken = this.sessionToken ?? readStorage(SESSION_TOKEN_KEY); if (storedToken && storedToken !== this.sessionToken) { this.setSessionToken(storedToken); diff --git a/app/gateway-frontend/e2e/lobby-game-auth.spec.ts b/app/gateway-frontend/e2e/lobby-game-auth.spec.ts index b29e14fc..4b4cf0c2 100644 --- a/app/gateway-frontend/e2e/lobby-game-auth.spec.ts +++ b/app/gateway-frontend/e2e/lobby-game-auth.spec.ts @@ -189,10 +189,7 @@ test('exchanges the gateway token before loading authenticated lobby general dat await expect(row).toContainText('선택장수'); await expect(row.getByRole('button', { name: '입장' })).toBeVisible(); const portrait = row.locator('img'); - await expect(portrait).toHaveAttribute( - 'src', - 'https://sam-image.hided.net/icons/users/core2026/account-hash.png' - ); + await expect(portrait).toHaveAttribute('src', 'https://sam-image.hided.net/icons/users/core2026/account-hash.png'); await expect.poll(() => portrait.evaluate((image: HTMLImageElement) => image.naturalWidth)).toBe(1); expect(gameOperations.find(({ operation }) => operation === 'auth.exchangeGatewayToken')).toEqual({ @@ -219,7 +216,32 @@ test('applies the signed general-acquisition policy to both create and possessio await expect(row.getByRole('button', { name: '장수빙의' })).toBeDisabled(); }); -test('opens the mode-1 possession tab with a fresh gateway game token', async ({ page }) => { +test('opens the profile root without profile or game token query parameters', async ({ page }) => { + await installFixture(page); + await page.route('**/hwe/', async (route) => { + await route.fulfill({ + status: 200, + contentType: 'text/html', + body: 'Clean profile target', + }); + }); + + await page.goto('lobby'); + const row = page.locator('tbody tr').filter({ hasText: 'hwe섭' }); + await row.getByRole('button', { name: '입장', exact: true }).click(); + + await expect(page).toHaveURL(/\/hwe\/$/); + const target = new URL(page.url()); + expect(target.search).toBe(''); + expect( + await page.evaluate(() => JSON.parse(window.sessionStorage.getItem('sammo-pending-game-session') ?? 'null')) + ).toEqual({ + profile: 'hwe:903', + gatewayToken: 'encrypted-gateway-game-token', + }); +}); + +test('opens the mode-1 possession route with a fresh gateway game token outside the URL', async ({ page }) => { await installFixture(page, { myGeneral: null, selectionPoolEnabled: false, @@ -238,11 +260,17 @@ test('opens the mode-1 possession tab with a fresh gateway game token', async ({ await expect(row.getByRole('button', { name: '장수빙의' })).toBeEnabled(); await row.getByRole('button', { name: '장수빙의' }).click(); - await expect(page).toHaveURL(/\/hwe\/join\?/); + await expect(page).toHaveURL(/\/hwe\/join\?tab=possess$/); const target = new URL(page.url()); expect(target.searchParams.get('tab')).toBe('possess'); - expect(target.searchParams.get('profile')).toBe('hwe:903'); - expect(target.searchParams.get('gameToken')).toBe('encrypted-gateway-game-token'); + expect(target.searchParams.has('profile')).toBe(false); + expect(target.searchParams.has('gameToken')).toBe(false); + expect( + await page.evaluate(() => JSON.parse(window.sessionStorage.getItem('sammo-pending-game-session') ?? 'null')) + ).toEqual({ + profile: 'hwe:903', + gatewayToken: 'encrypted-gateway-game-token', + }); }); test('shows registration closed instead of acquisition actions at the Ref capacity boundary', async ({ page }) => { diff --git a/app/gateway-frontend/src/views/LobbyView.vue b/app/gateway-frontend/src/views/LobbyView.vue index 35cc7dc7..69de63b8 100644 --- a/app/gateway-frontend/src/views/LobbyView.vue +++ b/app/gateway-frontend/src/views/LobbyView.vue @@ -3,6 +3,7 @@ import { computed, ref, onMounted, watch } from 'vue'; import { useRouter } from 'vue-router'; import type { inferRouterOutputs } from '@trpc/server'; import type { AppRouter } from '@sammo-ts/gateway-api'; +import { writeGameSessionTransfer } from '@sammo-ts/common/auth/gameSessionTransfer'; import DefaultLayout from '../layouts/DefaultLayout.vue'; import MapPreview from '../components/MapPreview.vue'; import { useToast } from '../composables/useToast'; @@ -227,8 +228,21 @@ const resolveGameUrl = (path: string, profileName: string, gameToken: string): s const base = new URL(baseUrl, window.location.origin); const normalizedPath = path.replace(/^\//, ''); const url = new URL(normalizedPath, base); - url.searchParams.set('profile', profileName); - url.searchParams.set('gameToken', gameToken); + let transferredInSessionStorage = false; + if (url.origin === window.location.origin) { + try { + transferredInSessionStorage = writeGameSessionTransfer(window.sessionStorage, { + profile: profileName, + gatewayToken: gameToken, + }); + } catch { + transferredInSessionStorage = false; + } + } + if (!transferredInSessionStorage) { + url.searchParams.set('profile', profileName); + url.searchParams.set('gameToken', gameToken); + } return url.toString(); }; @@ -571,7 +585,10 @@ const handleEnter = async (profile: LobbyProfile, targetPath: string) => { :map-layout="selectedMapPreview.mapLayout" mode="detail" /> -
+
유저 {{ profileDetails[selectedMapProfile.profileName]?.userCnt ?? '-' }} / {{ profileDetails[selectedMapProfile.profileName]?.maxUserCnt ?? '-' }} · {{ profileDetails[selectedMapProfile.profileName]?.nationCnt ?? '-' }}국 · diff --git a/packages/common/package.json b/packages/common/package.json index dc314c63..8332a196 100644 --- a/packages/common/package.json +++ b/packages/common/package.json @@ -17,6 +17,10 @@ "./auth/sanctions": { "types": "./dist/auth/sanctions.d.ts", "default": "./dist/auth/sanctions.js" + }, + "./auth/gameSessionTransfer": { + "types": "./dist/auth/gameSessionTransfer.d.ts", + "default": "./dist/auth/gameSessionTransfer.js" } }, "scripts": { diff --git a/packages/common/src/auth/gameSessionTransfer.ts b/packages/common/src/auth/gameSessionTransfer.ts new file mode 100644 index 00000000..a8902826 --- /dev/null +++ b/packages/common/src/auth/gameSessionTransfer.ts @@ -0,0 +1,56 @@ +export const GAME_SESSION_TRANSFER_STORAGE_KEY = 'sammo-pending-game-session'; + +export interface GameSessionTransfer { + profile: string; + gatewayToken: string; +} + +export interface GameSessionTransferStorage { + getItem(key: string): string | null; + setItem(key: string, value: string): void; + removeItem(key: string): void; +} + +const isGameSessionTransfer = (value: unknown): value is GameSessionTransfer => { + if (!value || typeof value !== 'object') { + return false; + } + const candidate = value as Partial; + return ( + typeof candidate.profile === 'string' && + candidate.profile.trim().length > 0 && + typeof candidate.gatewayToken === 'string' && + candidate.gatewayToken.trim().length > 0 + ); +}; + +export const writeGameSessionTransfer = ( + storage: GameSessionTransferStorage, + transfer: GameSessionTransfer +): boolean => { + try { + storage.setItem(GAME_SESSION_TRANSFER_STORAGE_KEY, JSON.stringify(transfer)); + return true; + } catch { + return false; + } +}; + +export const takeGameSessionTransfer = (storage: GameSessionTransferStorage): GameSessionTransfer | null => { + let raw: string | null; + try { + raw = storage.getItem(GAME_SESSION_TRANSFER_STORAGE_KEY); + storage.removeItem(GAME_SESSION_TRANSFER_STORAGE_KEY); + } catch { + return null; + } + if (!raw) { + return null; + } + try { + const parsed: unknown = JSON.parse(raw); + return isGameSessionTransfer(parsed) ? parsed : null; + } catch { + return null; + } +}; diff --git a/packages/common/test/game-session-transfer.test.ts b/packages/common/test/game-session-transfer.test.ts new file mode 100644 index 00000000..5de59223 --- /dev/null +++ b/packages/common/test/game-session-transfer.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from 'vitest'; +import { + GAME_SESSION_TRANSFER_STORAGE_KEY, + takeGameSessionTransfer, + writeGameSessionTransfer, + type GameSessionTransferStorage, +} from '../src/auth/gameSessionTransfer.js'; + +const createStorage = (): GameSessionTransferStorage & { values: Map } => { + const values = new Map(); + return { + values, + getItem: (key) => values.get(key) ?? null, + setItem: (key, value) => values.set(key, value), + removeItem: (key) => values.delete(key), + }; +}; + +describe('game session transfer', () => { + it('stores and consumes a valid transfer exactly once', () => { + const storage = createStorage(); + const transfer = { profile: 'hwe:903', gatewayToken: 'encrypted-token' }; + + expect(writeGameSessionTransfer(storage, transfer)).toBe(true); + expect(storage.values.has(GAME_SESSION_TRANSFER_STORAGE_KEY)).toBe(true); + expect(takeGameSessionTransfer(storage)).toEqual(transfer); + expect(takeGameSessionTransfer(storage)).toBeNull(); + }); + + it('removes malformed transfer data without returning it', () => { + const storage = createStorage(); + storage.setItem(GAME_SESSION_TRANSFER_STORAGE_KEY, JSON.stringify({ profile: 'hwe:903' })); + + expect(takeGameSessionTransfer(storage)).toBeNull(); + expect(storage.values.has(GAME_SESSION_TRANSFER_STORAGE_KEY)).toBe(false); + }); + + it('reports unavailable storage so callers can use a compatibility fallback', () => { + const unavailable: GameSessionTransferStorage = { + getItem: () => { + throw new Error('blocked'); + }, + setItem: () => { + throw new Error('blocked'); + }, + removeItem: () => { + throw new Error('blocked'); + }, + }; + + expect(writeGameSessionTransfer(unavailable, { profile: 'hwe:903', gatewayToken: 'token' })).toBe(false); + expect(takeGameSessionTransfer(unavailable)).toBeNull(); + }); +}); diff --git a/packages/common/tsdown.config.ts b/packages/common/tsdown.config.ts index fbe2c415..7c8644b1 100644 --- a/packages/common/tsdown.config.ts +++ b/packages/common/tsdown.config.ts @@ -4,6 +4,7 @@ export default defineConfig({ entry: { index: 'src/index.ts', 'auth/gameToken': 'src/auth/gameToken.ts', + 'auth/gameSessionTransfer': 'src/auth/gameSessionTransfer.ts', 'auth/sanctions': 'src/auth/sanctions.ts', }, format: 'es',