feat: add access token management and gateway token exchange functionality
This commit is contained in:
@@ -0,0 +1,119 @@
|
|||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
|
|
||||||
|
import type { GameSessionTokenPayload } from '@sammo-ts/common';
|
||||||
|
import { isValid, parseISO } from 'date-fns';
|
||||||
|
|
||||||
|
interface RedisClientLike {
|
||||||
|
get(key: string): Promise<string | null>;
|
||||||
|
set(key: string, value: string, options?: { EX?: number; NX?: boolean }): Promise<string | null>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ACCESS_TOKEN_PREFIX = 'ga_';
|
||||||
|
|
||||||
|
const buildAccessKey = (profileName: string, token: string): string =>
|
||||||
|
`sammo:game:access:${profileName}:${token}`;
|
||||||
|
|
||||||
|
const buildGatewayUsedKey = (profileName: string, sessionId: string): string =>
|
||||||
|
`sammo:game:gateway-used:${profileName}:${sessionId}`;
|
||||||
|
|
||||||
|
const parsePayload = (value: unknown): GameSessionTokenPayload | null => {
|
||||||
|
if (!value || typeof value !== 'object') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const payload = value as Partial<GameSessionTokenPayload>;
|
||||||
|
if (payload.version !== 1) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (typeof payload.profile !== 'string') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (typeof payload.issuedAt !== 'string' || typeof payload.expiresAt !== 'string') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (typeof payload.sessionId !== 'string') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (!payload.user || typeof payload.user !== 'object') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const user = payload.user as Partial<GameSessionTokenPayload['user']>;
|
||||||
|
if (
|
||||||
|
typeof user.id !== 'string' ||
|
||||||
|
typeof user.username !== 'string' ||
|
||||||
|
typeof user.displayName !== 'string' ||
|
||||||
|
!Array.isArray(user.roles)
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (!payload.sanctions || typeof payload.sanctions !== 'object') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return payload as GameSessionTokenPayload;
|
||||||
|
};
|
||||||
|
|
||||||
|
const resolveTtlSeconds = (expiresAt: string): number => {
|
||||||
|
const parsed = parseISO(expiresAt);
|
||||||
|
if (!isValid(parsed)) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
const ttl = Math.floor((parsed.getTime() - Date.now()) / 1000);
|
||||||
|
return ttl > 0 ? ttl : 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
export class RedisAccessTokenStore {
|
||||||
|
private readonly client: RedisClientLike;
|
||||||
|
private readonly profileName: string;
|
||||||
|
|
||||||
|
constructor(client: RedisClientLike, profileName: string) {
|
||||||
|
this.client = client;
|
||||||
|
this.profileName = profileName;
|
||||||
|
}
|
||||||
|
|
||||||
|
static isAccessToken(token: string): boolean {
|
||||||
|
return token.startsWith(ACCESS_TOKEN_PREFIX);
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(payload: GameSessionTokenPayload): Promise<{ accessToken: string; expiresAt: string } | null> {
|
||||||
|
const ttlSeconds = resolveTtlSeconds(payload.expiresAt);
|
||||||
|
if (ttlSeconds <= 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const accessToken = `${ACCESS_TOKEN_PREFIX}${randomUUID()}`;
|
||||||
|
const key = buildAccessKey(this.profileName, accessToken);
|
||||||
|
await this.client.set(key, JSON.stringify(payload), { EX: ttlSeconds });
|
||||||
|
return { accessToken, expiresAt: payload.expiresAt };
|
||||||
|
}
|
||||||
|
|
||||||
|
async get(accessToken: string): Promise<GameSessionTokenPayload | null> {
|
||||||
|
if (!RedisAccessTokenStore.isAccessToken(accessToken)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const key = buildAccessKey(this.profileName, accessToken);
|
||||||
|
const raw = await this.client.get(key);
|
||||||
|
if (!raw) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const payload = parsePayload(JSON.parse(raw));
|
||||||
|
if (!payload) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const ttl = resolveTtlSeconds(payload.expiresAt);
|
||||||
|
if (ttl <= 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return payload;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async markGatewayTokenUsed(sessionId: string, ttlSeconds: number): Promise<boolean> {
|
||||||
|
if (ttlSeconds <= 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const key = buildGatewayUsedKey(this.profileName, sessionId);
|
||||||
|
const result = await this.client.set(key, '1', { NX: true, EX: ttlSeconds });
|
||||||
|
return result === 'OK';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,8 @@ import type { DatabaseClient as InfraDatabaseClient, RedisConnector, GamePrisma
|
|||||||
|
|
||||||
import type { TurnDaemonTransport } from './daemon/transport.js';
|
import type { TurnDaemonTransport } from './daemon/transport.js';
|
||||||
import type { BattleSimTransport } from './battleSim/transport.js';
|
import type { BattleSimTransport } from './battleSim/transport.js';
|
||||||
|
import type { FlushStore } from './auth/flushStore.js';
|
||||||
|
import type { RedisAccessTokenStore } from './auth/accessTokenStore.js';
|
||||||
|
|
||||||
export interface GameProfile {
|
export interface GameProfile {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -48,6 +50,9 @@ export interface GameApiContext {
|
|||||||
battleSim: BattleSimTransport;
|
battleSim: BattleSimTransport;
|
||||||
profile: GameProfile;
|
profile: GameProfile;
|
||||||
auth: GameSessionTokenPayload | null;
|
auth: GameSessionTokenPayload | null;
|
||||||
|
accessTokenStore: RedisAccessTokenStore;
|
||||||
|
flushStore: FlushStore;
|
||||||
|
gameTokenSecret: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const createGameApiContext = (options: {
|
export const createGameApiContext = (options: {
|
||||||
@@ -57,6 +62,9 @@ export const createGameApiContext = (options: {
|
|||||||
battleSim: BattleSimTransport;
|
battleSim: BattleSimTransport;
|
||||||
profile: GameProfile;
|
profile: GameProfile;
|
||||||
auth: GameSessionTokenPayload | null;
|
auth: GameSessionTokenPayload | null;
|
||||||
|
accessTokenStore: RedisAccessTokenStore;
|
||||||
|
flushStore: FlushStore;
|
||||||
|
gameTokenSecret: string;
|
||||||
}): GameApiContext => {
|
}): GameApiContext => {
|
||||||
return {
|
return {
|
||||||
db: options.db,
|
db: options.db,
|
||||||
@@ -65,5 +73,8 @@ export const createGameApiContext = (options: {
|
|||||||
battleSim: options.battleSim,
|
battleSim: options.battleSim,
|
||||||
profile: options.profile,
|
profile: options.profile,
|
||||||
auth: options.auth,
|
auth: options.auth,
|
||||||
|
accessTokenStore: options.accessTokenStore,
|
||||||
|
flushStore: options.flushStore,
|
||||||
|
gameTokenSecret: options.gameTokenSecret,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { router } from './trpc.js';
|
import { router } from './trpc.js';
|
||||||
|
|
||||||
import { battleRouter } from './router/battle/index.js';
|
import { battleRouter } from './router/battle/index.js';
|
||||||
|
import { authRouter } from './router/auth/index.js';
|
||||||
import { generalRouter } from './router/general/index.js';
|
import { generalRouter } from './router/general/index.js';
|
||||||
import { healthRouter } from './router/health/index.js';
|
import { healthRouter } from './router/health/index.js';
|
||||||
import { joinRouter } from './router/join/index.js';
|
import { joinRouter } from './router/join/index.js';
|
||||||
@@ -15,6 +16,7 @@ import { worldRouter } from './router/world/index.js';
|
|||||||
|
|
||||||
export const appRouter = router({
|
export const appRouter = router({
|
||||||
health: healthRouter,
|
health: healthRouter,
|
||||||
|
auth: authRouter,
|
||||||
lobby: lobbyRouter,
|
lobby: lobbyRouter,
|
||||||
public: publicRouter,
|
public: publicRouter,
|
||||||
join: joinRouter,
|
join: joinRouter,
|
||||||
|
|||||||
@@ -0,0 +1,99 @@
|
|||||||
|
import { TRPCError } from '@trpc/server';
|
||||||
|
import { decryptGameSessionToken } from '@sammo-ts/common';
|
||||||
|
import { isAfter, isValid, parseISO } from 'date-fns';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import type { GameSessionTokenPayload } from '@sammo-ts/common';
|
||||||
|
import { procedure, router } from '../../trpc.js';
|
||||||
|
|
||||||
|
const parseDate = (value: string): Date | null => {
|
||||||
|
const parsed = parseISO(value);
|
||||||
|
return isValid(parsed) ? parsed : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const resolveTtlSeconds = (expiresAt: string): number => {
|
||||||
|
const parsed = parseDate(expiresAt);
|
||||||
|
if (!parsed) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
const ttl = Math.floor((parsed.getTime() - Date.now()) / 1000);
|
||||||
|
return ttl > 0 ? ttl : 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
const verifyGatewayToken = (
|
||||||
|
token: string,
|
||||||
|
profileName: string,
|
||||||
|
secret: string
|
||||||
|
): GameSessionTokenPayload | null => {
|
||||||
|
const payload = decryptGameSessionToken(token, secret);
|
||||||
|
if (!payload) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (payload.profile !== profileName) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const expiresAt = parseDate(payload.expiresAt);
|
||||||
|
const issuedAt = parseDate(payload.issuedAt);
|
||||||
|
if (!expiresAt || !issuedAt) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (isAfter(new Date(), expiresAt)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return payload;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const authRouter = router({
|
||||||
|
exchangeGatewayToken: procedure
|
||||||
|
.input(z.object({ gatewayToken: z.string().min(1) }))
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
const payload = verifyGatewayToken(
|
||||||
|
input.gatewayToken,
|
||||||
|
ctx.profile.name,
|
||||||
|
ctx.gameTokenSecret
|
||||||
|
);
|
||||||
|
if (!payload) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: 'UNAUTHORIZED',
|
||||||
|
message: 'Invalid gateway token.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const flushedAt = ctx.flushStore.getFlushedAt(payload.user.id);
|
||||||
|
if (flushedAt && new Date(payload.issuedAt) <= flushedAt) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: 'UNAUTHORIZED',
|
||||||
|
message: 'Gateway token revoked.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const ttlSeconds = resolveTtlSeconds(payload.expiresAt);
|
||||||
|
if (ttlSeconds <= 0) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: 'UNAUTHORIZED',
|
||||||
|
message: 'Gateway token expired.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const used = await ctx.accessTokenStore.markGatewayTokenUsed(payload.sessionId, ttlSeconds);
|
||||||
|
if (!used) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: 'CONFLICT',
|
||||||
|
message: 'Gateway token already used.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const created = await ctx.accessTokenStore.create(payload);
|
||||||
|
if (!created) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: 'INTERNAL_SERVER_ERROR',
|
||||||
|
message: 'Failed to issue access token.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
accessToken: created.accessToken,
|
||||||
|
expiresAt: created.expiresAt,
|
||||||
|
issuedAt: payload.issuedAt,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
});
|
||||||
@@ -13,7 +13,7 @@ import { createGameApiContext, type DatabaseClient as _DatabaseClient } from './
|
|||||||
import { buildTurnDaemonStreamKeys } from './daemon/streamKeys.js';
|
import { buildTurnDaemonStreamKeys } from './daemon/streamKeys.js';
|
||||||
import { RedisTurnDaemonTransport } from './daemon/redisTransport.js';
|
import { RedisTurnDaemonTransport } from './daemon/redisTransport.js';
|
||||||
import { InMemoryFlushStore, RedisGatewayFlushSubscriber } from './auth/flushStore.js';
|
import { InMemoryFlushStore, RedisGatewayFlushSubscriber } from './auth/flushStore.js';
|
||||||
import { createGameTokenVerifier } from './auth/tokenVerifier.js';
|
import { RedisAccessTokenStore } from './auth/accessTokenStore.js';
|
||||||
import { appRouter } from './router.js';
|
import { appRouter } from './router.js';
|
||||||
import { buildBattleSimQueueKeys } from './battleSim/keys.js';
|
import { buildBattleSimQueueKeys } from './battleSim/keys.js';
|
||||||
import { RedisBattleSimTransport } from './battleSim/redisTransport.js';
|
import { RedisBattleSimTransport } from './battleSim/redisTransport.js';
|
||||||
@@ -55,11 +55,7 @@ export const createGameApiServer = async () => {
|
|||||||
await flushSubscriberClient.connect();
|
await flushSubscriberClient.connect();
|
||||||
const flushSubscriber = new RedisGatewayFlushSubscriber(flushSubscriberClient, config.flushChannel, flushStore);
|
const flushSubscriber = new RedisGatewayFlushSubscriber(flushSubscriberClient, config.flushChannel, flushStore);
|
||||||
await flushSubscriber.start();
|
await flushSubscriber.start();
|
||||||
const tokenVerifier = createGameTokenVerifier({
|
const accessTokenStore = new RedisAccessTokenStore(redis.client, config.profileName);
|
||||||
secret: config.gameTokenSecret,
|
|
||||||
profileName: config.profileName,
|
|
||||||
flushStore,
|
|
||||||
});
|
|
||||||
|
|
||||||
const app = fastify({
|
const app = fastify({
|
||||||
logger: true,
|
logger: true,
|
||||||
@@ -74,9 +70,16 @@ export const createGameApiServer = async () => {
|
|||||||
prefix: config.trpcPath,
|
prefix: config.trpcPath,
|
||||||
trpcOptions: {
|
trpcOptions: {
|
||||||
router: appRouter,
|
router: appRouter,
|
||||||
createContext: ({ req }: { req: FastifyRequest }) => {
|
createContext: async ({ req }: { req: FastifyRequest }) => {
|
||||||
const token = extractBearerToken(req.headers.authorization);
|
const token = extractBearerToken(req.headers.authorization);
|
||||||
const auth = token ? tokenVerifier.verify(token) : null;
|
let auth = null;
|
||||||
|
if (token) {
|
||||||
|
const stored = await accessTokenStore.get(token);
|
||||||
|
if (stored) {
|
||||||
|
const flushedAt = flushStore.getFlushedAt(stored.user.id);
|
||||||
|
auth = flushedAt && new Date(stored.issuedAt) <= flushedAt ? null : stored;
|
||||||
|
}
|
||||||
|
}
|
||||||
return createGameApiContext({
|
return createGameApiContext({
|
||||||
db: postgres.prisma,
|
db: postgres.prisma,
|
||||||
redis: redis.client,
|
redis: redis.client,
|
||||||
@@ -88,6 +91,9 @@ export const createGameApiServer = async () => {
|
|||||||
name: config.profileName,
|
name: config.profileName,
|
||||||
},
|
},
|
||||||
auth,
|
auth,
|
||||||
|
accessTokenStore,
|
||||||
|
flushStore,
|
||||||
|
gameTokenSecret: config.gameTokenSecret,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import type { DatabaseClient, GameApiContext, GameProfile, WorldStateRow } from
|
|||||||
import type { RedisConnector } from '@sammo-ts/infra';
|
import type { RedisConnector } from '@sammo-ts/infra';
|
||||||
import { InMemoryBattleSimTransport } from '../src/battleSim/inMemoryTransport.js';
|
import { InMemoryBattleSimTransport } from '../src/battleSim/inMemoryTransport.js';
|
||||||
import { InMemoryTurnDaemonTransport } from '../src/daemon/inMemoryTransport.js';
|
import { InMemoryTurnDaemonTransport } from '../src/daemon/inMemoryTransport.js';
|
||||||
|
import { InMemoryFlushStore } from '../src/auth/flushStore.js';
|
||||||
|
import { RedisAccessTokenStore } from '../src/auth/accessTokenStore.js';
|
||||||
import { appRouter } from '../src/router.js';
|
import { appRouter } from '../src/router.js';
|
||||||
|
|
||||||
const profile: GameProfile = {
|
const profile: GameProfile = {
|
||||||
@@ -43,6 +45,13 @@ const buildContext = (options?: {
|
|||||||
createMany: async () => ({}),
|
createMany: async () => ({}),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
const accessTokenStore = new RedisAccessTokenStore(
|
||||||
|
{
|
||||||
|
get: async () => null,
|
||||||
|
set: async () => null,
|
||||||
|
},
|
||||||
|
profile.name
|
||||||
|
);
|
||||||
return {
|
return {
|
||||||
db: db as unknown as DatabaseClient,
|
db: db as unknown as DatabaseClient,
|
||||||
turnDaemon: transport,
|
turnDaemon: transport,
|
||||||
@@ -50,6 +59,9 @@ const buildContext = (options?: {
|
|||||||
profile,
|
profile,
|
||||||
auth: null,
|
auth: null,
|
||||||
redis: {} as unknown as RedisConnector['client'],
|
redis: {} as unknown as RedisConnector['client'],
|
||||||
|
accessTokenStore,
|
||||||
|
flushStore: new InMemoryFlushStore(),
|
||||||
|
gameTokenSecret: 'test-secret',
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ interface SessionState {
|
|||||||
const SESSION_TOKEN_KEY = 'sammo-session-token';
|
const SESSION_TOKEN_KEY = 'sammo-session-token';
|
||||||
const PROFILE_KEY = 'sammo-game-profile';
|
const PROFILE_KEY = 'sammo-game-profile';
|
||||||
const GAME_TOKEN_KEY = 'sammo-game-token';
|
const GAME_TOKEN_KEY = 'sammo-game-token';
|
||||||
|
const ACCESS_TOKEN_PREFIX = 'ga_';
|
||||||
|
|
||||||
const readStorage = (key: string): string | null => {
|
const readStorage = (key: string): string | null => {
|
||||||
if (typeof window === 'undefined') {
|
if (typeof window === 'undefined') {
|
||||||
@@ -56,6 +57,13 @@ const readQueryParam = (key: string): string | null => {
|
|||||||
return value;
|
return value;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const isAccessToken = (token: string | null): boolean => {
|
||||||
|
if (!token) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return token.startsWith(ACCESS_TOKEN_PREFIX);
|
||||||
|
};
|
||||||
|
|
||||||
export const useSessionStore = defineStore('session', {
|
export const useSessionStore = defineStore('session', {
|
||||||
state: (): SessionState => ({
|
state: (): SessionState => ({
|
||||||
status: 'unknown',
|
status: 'unknown',
|
||||||
@@ -96,6 +104,13 @@ export const useSessionStore = defineStore('session', {
|
|||||||
this.status = 'authed';
|
this.status = 'authed';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (!isAccessToken(this.gameToken)) {
|
||||||
|
const exchanged = await this.exchangeGatewayToken();
|
||||||
|
if (!exchanged) {
|
||||||
|
this.status = this.sessionToken ? 'authed' : 'public';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const lobby = await gameTrpc.lobby.info.query();
|
const lobby = await gameTrpc.lobby.info.query();
|
||||||
this.status = lobby.myGeneral ? 'general' : 'authed';
|
this.status = lobby.myGeneral ? 'general' : 'authed';
|
||||||
@@ -103,6 +118,22 @@ export const useSessionStore = defineStore('session', {
|
|||||||
this.error = 'game_status_unavailable';
|
this.error = 'game_status_unavailable';
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
async exchangeGatewayToken(): Promise<boolean> {
|
||||||
|
if (!this.gameToken || isAccessToken(this.gameToken)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const exchanged = await gameTrpc.auth.exchangeGatewayToken.mutate({
|
||||||
|
gatewayToken: this.gameToken,
|
||||||
|
});
|
||||||
|
this.setGameToken(exchanged.accessToken);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
this.error = 'game_token_exchange_failed';
|
||||||
|
this.setGameToken(null);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
},
|
||||||
async initialize() {
|
async initialize() {
|
||||||
if (this.initializing || this.status !== 'unknown') {
|
if (this.initializing || this.status !== 'unknown') {
|
||||||
return;
|
return;
|
||||||
@@ -121,6 +152,11 @@ export const useSessionStore = defineStore('session', {
|
|||||||
this.setProfile(profileFromQuery);
|
this.setProfile(profileFromQuery);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const gatewayTokenFromQuery = readQueryParam('gameToken');
|
||||||
|
if (gatewayTokenFromQuery) {
|
||||||
|
this.setGameToken(gatewayTokenFromQuery);
|
||||||
|
}
|
||||||
|
|
||||||
const storedToken = this.sessionToken ?? readStorage(SESSION_TOKEN_KEY);
|
const storedToken = this.sessionToken ?? readStorage(SESSION_TOKEN_KEY);
|
||||||
if (storedToken && storedToken !== this.sessionToken) {
|
if (storedToken && storedToken !== this.sessionToken) {
|
||||||
this.setSessionToken(storedToken);
|
this.setSessionToken(storedToken);
|
||||||
@@ -131,30 +167,34 @@ export const useSessionStore = defineStore('session', {
|
|||||||
this.setProfile(storedProfile);
|
this.setProfile(storedProfile);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!this.sessionToken) {
|
if (!this.sessionToken && !this.gameToken) {
|
||||||
this.status = 'public';
|
this.status = 'public';
|
||||||
this.initializing = false;
|
this.initializing = false;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
if (this.sessionToken) {
|
||||||
const me = await gatewayTrpc.me.query();
|
try {
|
||||||
if (!me) {
|
const me = await gatewayTrpc.me.query();
|
||||||
this.clearSession();
|
if (!me) {
|
||||||
|
this.clearSession();
|
||||||
|
this.initializing = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.user = {
|
||||||
|
id: me.id,
|
||||||
|
username: me.username,
|
||||||
|
displayName: me.displayName,
|
||||||
|
};
|
||||||
|
this.status = 'authed';
|
||||||
|
} catch {
|
||||||
|
this.error = 'gateway_unavailable';
|
||||||
|
this.status = 'public';
|
||||||
this.initializing = false;
|
this.initializing = false;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.user = {
|
} else {
|
||||||
id: me.id,
|
|
||||||
username: me.username,
|
|
||||||
displayName: me.displayName,
|
|
||||||
};
|
|
||||||
this.status = 'authed';
|
this.status = 'authed';
|
||||||
} catch {
|
|
||||||
this.error = 'gateway_unavailable';
|
|
||||||
this.status = 'public';
|
|
||||||
this.initializing = false;
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!this.profile) {
|
if (!this.profile) {
|
||||||
@@ -163,17 +203,16 @@ export const useSessionStore = defineStore('session', {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const sessionToken = this.sessionToken;
|
if (!this.gameToken || !isAccessToken(this.gameToken)) {
|
||||||
if (!sessionToken) {
|
const sessionToken = this.sessionToken;
|
||||||
this.status = 'public';
|
if (sessionToken) {
|
||||||
this.initializing = false;
|
const issued = await gatewayTrpc.auth.issueGameSession.mutate({
|
||||||
return;
|
sessionToken,
|
||||||
|
profile: this.profile,
|
||||||
|
});
|
||||||
|
this.setGameToken(issued.gameToken);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
const issued = await gatewayTrpc.auth.issueGameSession.mutate({
|
|
||||||
sessionToken,
|
|
||||||
profile: this.profile,
|
|
||||||
});
|
|
||||||
this.setGameToken(issued.gameToken);
|
|
||||||
await this.refreshGeneralStatus();
|
await this.refreshGeneralStatus();
|
||||||
} catch {
|
} catch {
|
||||||
this.error = 'game_session_unavailable';
|
this.error = 'game_session_unavailable';
|
||||||
|
|||||||
Vendored
+8
@@ -5,3 +5,11 @@ declare module '*.vue' {
|
|||||||
const component: DefineComponent<{}, {}, any>;
|
const component: DefineComponent<{}, {}, any>;
|
||||||
export default component;
|
export default component;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface ImportMetaEnv {
|
||||||
|
readonly VITE_GAME_WEB_URL?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ImportMeta {
|
||||||
|
readonly env: ImportMetaEnv;
|
||||||
|
}
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ const notice = ref('');
|
|||||||
const profiles = ref<LobbyProfile[]>([]);
|
const profiles = ref<LobbyProfile[]>([]);
|
||||||
const profileDetails = ref<Record<string, LobbyInfo | undefined>>({});
|
const profileDetails = ref<Record<string, LobbyInfo | undefined>>({});
|
||||||
const profileMapPreviews = ref<Record<string, MapPreviewBundle | undefined>>({});
|
const profileMapPreviews = ref<Record<string, MapPreviewBundle | undefined>>({});
|
||||||
|
const entryLoading = ref<Record<string, boolean>>({});
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
try {
|
try {
|
||||||
@@ -75,6 +76,48 @@ const handleLogout = async () => {
|
|||||||
// await trpc.auth.logout.mutation();
|
// await trpc.auth.logout.mutation();
|
||||||
await router.push('/');
|
await router.push('/');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const resolveGameUrl = (path: string, profileName: string, gameToken: string): string | null => {
|
||||||
|
const baseUrl = import.meta.env.VITE_GAME_WEB_URL ?? '';
|
||||||
|
if (!baseUrl) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
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);
|
||||||
|
return url.toString();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEnter = async (profile: LobbyProfile, targetPath: string) => {
|
||||||
|
if (entryLoading.value[profile.profileName]) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const sessionToken = window.localStorage.getItem('sammo-session-token');
|
||||||
|
if (!sessionToken) {
|
||||||
|
await router.push('/');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
entryLoading.value[profile.profileName] = true;
|
||||||
|
try {
|
||||||
|
const issued = await trpc.auth.issueGameSession.mutate({
|
||||||
|
sessionToken,
|
||||||
|
profile: profile.profileName,
|
||||||
|
});
|
||||||
|
const url = resolveGameUrl(targetPath, issued.profile, issued.gameToken);
|
||||||
|
if (!url) {
|
||||||
|
alert('게임 프론트엔드 주소가 설정되지 않았습니다.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
window.location.href = url;
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to issue game session', e);
|
||||||
|
alert('게임 서버 접속에 실패했습니다.');
|
||||||
|
} finally {
|
||||||
|
entryLoading.value[profile.profileName] = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -186,12 +229,16 @@ const handleLogout = async () => {
|
|||||||
<button
|
<button
|
||||||
v-if="profileDetails[profile.profileName]?.myGeneral"
|
v-if="profileDetails[profile.profileName]?.myGeneral"
|
||||||
class="w-full bg-zinc-700 hover:bg-zinc-600 text-white py-1.5 rounded text-sm transition-colors"
|
class="w-full bg-zinc-700 hover:bg-zinc-600 text-white py-1.5 rounded text-sm transition-colors"
|
||||||
|
:disabled="entryLoading[profile.profileName]"
|
||||||
|
@click="handleEnter(profile, '/')"
|
||||||
>
|
>
|
||||||
입장
|
입장
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
v-else
|
v-else
|
||||||
class="w-full bg-zinc-700 hover:bg-zinc-600 text-white py-1.5 rounded text-sm transition-colors"
|
class="w-full bg-zinc-700 hover:bg-zinc-600 text-white py-1.5 rounded text-sm transition-colors"
|
||||||
|
:disabled="entryLoading[profile.profileName]"
|
||||||
|
@click="handleEnter(profile, '/join')"
|
||||||
>
|
>
|
||||||
장수생성
|
장수생성
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -135,10 +135,12 @@
|
|||||||
- 게임 API: `join.getConfig`, `join.createGeneral`, `join.listPossessCandidates`, `join.possessGeneral` 추가
|
- 게임 API: `join.getConfig`, `join.createGeneral`, `join.listPossessCandidates`, `join.possessGeneral` 추가
|
||||||
- Public 화면 구현: 캐시 지도/중원 정세/세력 일람/제한 장수 일람 구성
|
- Public 화면 구현: 캐시 지도/중원 정세/세력 일람/제한 장수 일람 구성
|
||||||
- Public API: `public.getCachedMap`, `public.getWorldTrend`, `public.getNationList`, `public.getGeneralList` 추가
|
- Public API: `public.getCachedMap`, `public.getWorldTrend`, `public.getNationList`, `public.getGeneralList` 추가
|
||||||
|
- Gateway → Game handoff: 게이트웨이에서 `gameToken` 발급 후 게임 프론트에서 1회 교환(access token)하는 흐름 추가
|
||||||
|
|
||||||
## Next Frontend Tasks
|
## Next Frontend Tasks
|
||||||
|
|
||||||
- 게이트웨이 로그인/프로필 선택 플로우 정리 (토큰 전달 방식, 자동 로그인, 쿠키 기반 전환 고려)
|
- 게이트웨이 로그인/프로필 선택 플로우 정리 (토큰 전달 방식, 자동 로그인, 쿠키 기반 전환 고려)
|
||||||
|
- 게이트웨이/게임 프론트 도메인 경로(`VITE_GAME_WEB_URL`) 확정 및 운영 배포 경로 문서화
|
||||||
- 실시간 업데이트(SSE) 연결 설계 및 메인 화면 토글과 연동
|
- 실시간 업데이트(SSE) 연결 설계 및 메인 화면 토글과 연동
|
||||||
- MapViewer 비주얼 보강: 레거시 테마/아이콘/맵 배경 스타일 상세 이식
|
- MapViewer 비주얼 보강: 레거시 테마/아이콘/맵 배경 스타일 상세 이식
|
||||||
- 레거시 이미지 서빙 위치 확정 및 SPA 배포 시 정적 경로 매핑
|
- 레거시 이미지 서빙 위치 확정 및 SPA 배포 시 정적 경로 매핑
|
||||||
|
|||||||
Reference in New Issue
Block a user