From b7d39acca52f284513aba4fdbc9de5bf5078de0a Mon Sep 17 00:00:00 2001 From: Govert de Gans Date: Tue, 19 May 2026 16:50:09 +0200 Subject: [PATCH 1/3] Rename some params in read/writeExternalUserData for clarity --- components/databaseHandler.ts | 28 ++++++++++++++++++++-------- 1 file changed, 20 insertions(+), 8 deletions(-) diff --git a/components/databaseHandler.ts b/components/databaseHandler.ts index 5374327ad..3c14abc4b 100644 --- a/components/databaseHandler.ts +++ b/components/databaseHandler.ts @@ -289,12 +289,12 @@ export function writeNewUserData( /** * Gets the value of an external provider binding. * - * @param userId The user's ID. + * @param externalUserId The user's ID. * @param externalFolder The folder where this provider's users are stored. * @param gameVersion The game's version. */ export async function getExternalUserData( - userId: string, + externalUserId: string, externalFolder: string, gameVersion: GameVersion, ): Promise { @@ -303,26 +303,33 @@ export async function getExternalUserData( if (["scpc", "h1", "h2"].includes(gameVersion)) { return ( await fs.readFile( - join("userdata", gameVersion, externalFolder, `${userId}.json`), + join( + "userdata", + gameVersion, + externalFolder, + `${externalUserId}.json`, + ), ) ).toString() } return ( - await fs.readFile(join("userdata", externalFolder, `${userId}.json`)) + await fs.readFile( + join("userdata", externalFolder, `${externalUserId}.json`), + ) ).toString() } /** * Writes the value of an external provider binding. * - * @param userId The user's ID. + * @param externalUserId The user's ID. * @param externalFolder The folder where this provider's users are stored. * @param userData The data to write to the binding. * @param gameVersion The game's version. */ export async function writeExternalUserData( - userId: string, + externalUserId: string, externalFolder: string, userData: string, gameVersion: GameVersion, @@ -331,13 +338,18 @@ export async function writeExternalUserData( if (["scpc", "h1", "h2"].includes(gameVersion)) { return await fs.writeFile( - join("userdata", gameVersion, externalFolder, `${userId}.json`), + join( + "userdata", + gameVersion, + externalFolder, + `${externalUserId}.json`, + ), userData, ) } return await fs.writeFile( - join("userdata", externalFolder, `${userId}.json`), + join("userdata", externalFolder, `${externalUserId}.json`), userData, ) } From 2fc8dab499d86b3ce8eef810dbf1e64d0d586c82 Mon Sep 17 00:00:00 2001 From: Govert de Gans Date: Tue, 19 May 2026 17:09:11 +0200 Subject: [PATCH 2/3] Do not attempt progress transfer if official server auth failed --- components/webFeatures.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/components/webFeatures.ts b/components/webFeatures.ts index e95fe76cc..50b8a537a 100644 --- a/components/webFeatures.ts +++ b/components/webFeatures.ts @@ -352,7 +352,7 @@ webFeaturesRouter.post( const remoteService = getRemoteService(req.query.gv) const auth = userAuths.get(req.query.user) - if (!auth) { + if (!auth?.initialized) { formErrorMessage( res, "Failed to get official authentication data. Please connect to Peacock first.", From e52f3fd3e7667254380cf051950fd36a0f19cb61 Mon Sep 17 00:00:00 2001 From: Govert de Gans Date: Tue, 19 May 2026 17:25:01 +0200 Subject: [PATCH 3/3] fix: Correctly handle 410 responses in official server auth A 410 response occurs when IOI's server did not expect the supplied profile ID - either when the supplied profile ID is linked to a different external user id or when the profile ID does not exist at all. --- components/index.ts | 3 +++ components/oauthToken.ts | 40 ++++++++++++++++++--------- components/officialServerAuth.ts | 46 +++++++++++++++++++++++++------- components/webFeatures.ts | 4 +-- 4 files changed, 68 insertions(+), 25 deletions(-) diff --git a/components/index.ts b/components/index.ts index 5acf5e650..440316547 100644 --- a/components/index.ts +++ b/components/index.ts @@ -32,6 +32,7 @@ import { getConfig } from "./configSwizzleManager" import { error400, error406, + error410, handleOAuthToken, OAuthTokenBody, } from "./oauthToken" @@ -299,6 +300,8 @@ app.post( return res.status(400).send() } else if (token === error406) { return res.status(406).send() + } else if (token === error410) { + return res.status(410).send() } else { return res.json(token) } diff --git a/components/oauthToken.ts b/components/oauthToken.ts index 6b7ef671e..e6f6612a2 100644 --- a/components/oauthToken.ts +++ b/components/oauthToken.ts @@ -77,6 +77,7 @@ export type OAuthTokenResponse = { export const error400: unique symbol = Symbol("http400") export const error406: unique symbol = Symbol("http406") +export const error410: unique symbol = Symbol("http410") /** * This is the code that handles the OAuth token request. @@ -86,7 +87,9 @@ export const error406: unique symbol = Symbol("http406") */ export async function handleOAuthToken( req: RequestWithJwt, -): Promise { +): Promise< + typeof error400 | typeof error406 | typeof error410 | OAuthTokenResponse +> { const isScpc = req.body.gs === "scpc-prod" const signOptions = { @@ -222,18 +225,29 @@ export async function handleOAuthToken( } } else { // if a profile id is supplied - getExternalUserData(external_userid, external_users_folder, gameVersion) - .then(() => null) - .catch(async () => { - // external id is not yet linked to this profile - await writeExternalUserData( - external_userid, - external_users_folder, - // we've already confirmed this will be there, and it's a GUID - req.body.pId!, - gameVersion, - ) - }) + const saved_profile_id = await getExternalUserData( + external_userid, + external_users_folder, + gameVersion, + ).catch(async () => { + // this external user id is not yet linked to any profile + await writeExternalUserData( + external_userid, + external_users_folder, + // we've already confirmed this will be there, and it's a GUID + req.body.pId!, + gameVersion, + ) + return req.body.pId! + }) + + if (saved_profile_id !== req.body.pId) { + log( + LogLevel.DEBUG, + `410: external user ${external_platform}:${external_userid} tried to login as ${req.body.pId}.`, + ) + return error410 // this external user id is linked to a different profile id than the one supplied. + } } try { diff --git a/components/officialServerAuth.ts b/components/officialServerAuth.ts index 8d868cc5e..2b25bd2ba 100644 --- a/components/officialServerAuth.ts +++ b/components/officialServerAuth.ts @@ -18,9 +18,10 @@ import axios, { AxiosError, AxiosResponse } from "axios" import type { Request } from "express" +import { decode } from "jsonwebtoken" import { log, LogLevel } from "./loggingInterop" import { handleAxiosError } from "./utils" -import type { GameVersion } from "./types/types" +import type { GameVersion, JwtData } from "./types/types" /* eslint-disable @typescript-eslint/no-explicit-any */ @@ -71,6 +72,7 @@ export class OfficialServerAuth { * If this authentication container is ready for use. */ initialized?: boolean + profileId?: string protected _usableToken?: string protected _refreshToken?: string protected _gameAuthToken?: string @@ -101,8 +103,11 @@ export class OfficialServerAuth { async _initiallyAuthenticate(req: Request): Promise { try { const r = await this._firstTimeObtainData(req) + const decodedToken = decode(r.access_token) as unknown as JwtData + this._usableToken = r.access_token this._refreshToken = r.refresh_token + this.profileId = decodedToken.unique_name this.initialized = true } catch (e) { handleAxiosError(e as AxiosError) @@ -183,15 +188,36 @@ export class OfficialServerAuth { * @returns The token data fetched from the official servers. */ private async _firstTimeObtainData(req: Request): Promise { - return ( - await axios.post( - "https://auth.hitman.io/oauth/token", - createUrlencodedBody(req.body), - { - headers: this._headers, - }, - ) - ).data + const requestBody = Object.assign({}, req.body) + + try { + return ( + await axios.post( + "https://auth.hitman.io/oauth/token", + createUrlencodedBody(requestBody), + { + headers: this._headers, + }, + ) + ).data + } catch (e) { + if (e instanceof AxiosError && e.status === 410) { + // IOI expected a different profile id for this platform id + delete requestBody.pId // Let IOI's server figure out the correct profile id + + return ( + await axios.post( + "https://auth.hitman.io/oauth/token", + createUrlencodedBody(requestBody), + { + headers: this._headers, + }, + ) + ).data + } else { + throw e + } + } } } diff --git a/components/webFeatures.ts b/components/webFeatures.ts index 50b8a537a..8a71724c5 100644 --- a/components/webFeatures.ts +++ b/components/webFeatures.ts @@ -376,7 +376,7 @@ webFeaturesRouter.post( `https://${remoteService}.hitman.io/authentication/api/userchannel/ChallengesService/GetProgression`, false, { - profileid: req.query.user, + profileid: auth.profileId, challengeids: controller.challengeService .getChallengeIds(req.query.gv) .filter((id) => uuidRegex.test(id)), // filter out potential bogus challenge ids added by plugins @@ -419,7 +419,7 @@ webFeaturesRouter.post( `https://${remoteService}.hitman.io/authentication/api/userchannel/ProfileService/GetProfile`, false, { - id: req.query.user, + id: auth.profileId, extensions: [ "achievements", "friends",