Skip to content

Commit 59ca865

Browse files
Add support for 410 responses in OAuth requests (#693)
Adds support for handling 410 responses coming from IOI's servers when sending an oauth request, and also adds support for sending 410 responses of our own in oauth requests. A response of `410 Gone` is sent by IOI's servers when sending a request to `/oauth/token` with `pId` set to a profile id that is not linked with the external user id used for the authentication. When the game receives such a response, it will retry the oauth request with `pId` set to an empty string.
2 parents c407faa + c4a1bc0 commit 59ca865

6 files changed

Lines changed: 90 additions & 35 deletions

File tree

components/databaseHandler.ts

Lines changed: 20 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -289,12 +289,12 @@ export function writeNewUserData(
289289
/**
290290
* Gets the value of an external provider binding.
291291
*
292-
* @param userId The user's ID.
292+
* @param externalUserId The user's ID.
293293
* @param externalFolder The folder where this provider's users are stored.
294294
* @param gameVersion The game's version.
295295
*/
296296
export async function getExternalUserData(
297-
userId: string,
297+
externalUserId: string,
298298
externalFolder: string,
299299
gameVersion: GameVersion,
300300
): Promise<string> {
@@ -303,26 +303,33 @@ export async function getExternalUserData(
303303
if (["scpc", "h1", "h2"].includes(gameVersion)) {
304304
return (
305305
await fs.readFile(
306-
join("userdata", gameVersion, externalFolder, `${userId}.json`),
306+
join(
307+
"userdata",
308+
gameVersion,
309+
externalFolder,
310+
`${externalUserId}.json`,
311+
),
307312
)
308313
).toString()
309314
}
310315

311316
return (
312-
await fs.readFile(join("userdata", externalFolder, `${userId}.json`))
317+
await fs.readFile(
318+
join("userdata", externalFolder, `${externalUserId}.json`),
319+
)
313320
).toString()
314321
}
315322

316323
/**
317324
* Writes the value of an external provider binding.
318325
*
319-
* @param userId The user's ID.
326+
* @param externalUserId The user's ID.
320327
* @param externalFolder The folder where this provider's users are stored.
321328
* @param userData The data to write to the binding.
322329
* @param gameVersion The game's version.
323330
*/
324331
export async function writeExternalUserData(
325-
userId: string,
332+
externalUserId: string,
326333
externalFolder: string,
327334
userData: string,
328335
gameVersion: GameVersion,
@@ -331,13 +338,18 @@ export async function writeExternalUserData(
331338

332339
if (["scpc", "h1", "h2"].includes(gameVersion)) {
333340
return await fs.writeFile(
334-
join("userdata", gameVersion, externalFolder, `${userId}.json`),
341+
join(
342+
"userdata",
343+
gameVersion,
344+
externalFolder,
345+
`${externalUserId}.json`,
346+
),
335347
userData,
336348
)
337349
}
338350

339351
return await fs.writeFile(
340-
join("userdata", externalFolder, `${userId}.json`),
352+
join("userdata", externalFolder, `${externalUserId}.json`),
341353
userData,
342354
)
343355
}

components/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ import { getConfig } from "./configSwizzleManager"
3232
import {
3333
error400,
3434
error406,
35+
error410,
3536
handleOAuthToken,
3637
OAuthTokenBody,
3738
} from "./oauthToken"
@@ -307,6 +308,8 @@ app.post(
307308
return res.status(400).send()
308309
} else if (token === error406) {
309310
return res.status(406).send()
311+
} else if (token === error410) {
312+
return res.status(410).send()
310313
} else {
311314
return res.json(token)
312315
}

components/oauthToken.ts

Lines changed: 27 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,7 @@ export type OAuthTokenResponse = {
8181

8282
export const error400: unique symbol = Symbol("http400")
8383
export const error406: unique symbol = Symbol("http406")
84+
export const error410: unique symbol = Symbol("http410")
8485

8586
/**
8687
* This is the code that handles the OAuth token request.
@@ -90,7 +91,9 @@ export const error406: unique symbol = Symbol("http406")
9091
*/
9192
export async function handleOAuthToken(
9293
req: RequestWithJwt<never, OAuthTokenBody>,
93-
): Promise<typeof error400 | typeof error406 | OAuthTokenResponse> {
94+
): Promise<
95+
typeof error400 | typeof error406 | typeof error410 | OAuthTokenResponse
96+
> {
9497
const isScpc = req.body.gs === "scpc-prod"
9598

9699
const signOptions = {
@@ -226,18 +229,29 @@ export async function handleOAuthToken(
226229
}
227230
} else {
228231
// if a profile id is supplied
229-
getExternalUserData(external_userid, external_users_folder, gameVersion)
230-
.then(() => null)
231-
.catch(async () => {
232-
// external id is not yet linked to this profile
233-
await writeExternalUserData(
234-
external_userid,
235-
external_users_folder,
236-
// we've already confirmed this will be there, and it's a GUID
237-
req.body.pId!,
238-
gameVersion,
239-
)
240-
})
232+
const saved_profile_id = await getExternalUserData(
233+
external_userid,
234+
external_users_folder,
235+
gameVersion,
236+
).catch(async () => {
237+
// this external user id is not yet linked to any profile
238+
await writeExternalUserData(
239+
external_userid,
240+
external_users_folder,
241+
// we've already confirmed this will be there, and it's a GUID
242+
req.body.pId!,
243+
gameVersion,
244+
)
245+
return req.body.pId!
246+
})
247+
248+
if (saved_profile_id !== req.body.pId) {
249+
log(
250+
LogLevel.DEBUG,
251+
`410: external user ${external_platform}:${external_userid} tried to login as ${req.body.pId}.`,
252+
)
253+
return error410 // this external user id is linked to a different profile id than the one supplied.
254+
}
241255
}
242256

243257
try {

components/officialServerAuth.ts

Lines changed: 36 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -18,9 +18,10 @@
1818

1919
import axios, { AxiosError, AxiosResponse } from "axios"
2020
import type { Request } from "express"
21+
import { decode } from "jsonwebtoken"
2122
import { log, LogLevel } from "./loggingInterop"
2223
import { handleAxiosError } from "./utils"
23-
import type { GameVersion } from "./types/types"
24+
import type { GameVersion, JwtData } from "./types/types"
2425

2526
/* eslint-disable @typescript-eslint/no-explicit-any */
2627

@@ -71,6 +72,7 @@ export class OfficialServerAuth {
7172
* If this authentication container is ready for use.
7273
*/
7374
initialized?: boolean
75+
profileId?: string
7476
protected _usableToken?: string
7577
protected _refreshToken?: string
7678
protected _gameAuthToken?: string
@@ -101,8 +103,11 @@ export class OfficialServerAuth {
101103
async _initiallyAuthenticate(req: Request): Promise<void> {
102104
try {
103105
const r = await this._firstTimeObtainData(req)
106+
const decodedToken = decode(r.access_token) as unknown as JwtData
107+
104108
this._usableToken = r.access_token
105109
this._refreshToken = r.refresh_token
110+
this.profileId = decodedToken.unique_name
106111
this.initialized = true
107112
} catch (e) {
108113
handleAxiosError(e as AxiosError)
@@ -183,15 +188,36 @@ export class OfficialServerAuth {
183188
* @returns The token data fetched from the official servers.
184189
*/
185190
private async _firstTimeObtainData(req: Request): Promise<AuthResponse> {
186-
return (
187-
await axios.post(
188-
"https://auth.hitman.io/oauth/token",
189-
createUrlencodedBody(req.body),
190-
{
191-
headers: this._headers,
192-
},
193-
)
194-
).data
191+
const requestBody = Object.assign({}, req.body)
192+
193+
try {
194+
return (
195+
await axios.post(
196+
"https://auth.hitman.io/oauth/token",
197+
createUrlencodedBody(requestBody),
198+
{
199+
headers: this._headers,
200+
},
201+
)
202+
).data
203+
} catch (e) {
204+
if (e instanceof AxiosError && e.status === 410) {
205+
// IOI expected a different profile id for this platform id
206+
delete requestBody.pId // Let IOI's server figure out the correct profile id
207+
208+
return (
209+
await axios.post(
210+
"https://auth.hitman.io/oauth/token",
211+
createUrlencodedBody(requestBody),
212+
{
213+
headers: this._headers,
214+
},
215+
)
216+
).data
217+
} else {
218+
throw e
219+
}
220+
}
195221
}
196222
}
197223

components/webFeatures.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -352,7 +352,7 @@ webFeaturesRouter.post(
352352
const remoteService = getRemoteService(req.query.gv)
353353
const auth = userAuths.get(req.query.user)
354354

355-
if (!auth) {
355+
if (!auth?.initialized) {
356356
formErrorMessage(
357357
res,
358358
"Failed to get official authentication data. Please connect to Peacock first.",
@@ -376,7 +376,7 @@ webFeaturesRouter.post(
376376
`https://${remoteService}.hitman.io/authentication/api/userchannel/ChallengesService/GetProgression`,
377377
false,
378378
{
379-
profileid: req.query.user,
379+
profileid: auth.profileId,
380380
challengeids: controller.challengeService
381381
.getChallengeIds(req.query.gv)
382382
.filter((id) => uuidRegex.test(id)), // filter out potential bogus challenge ids added by plugins
@@ -419,7 +419,7 @@ webFeaturesRouter.post(
419419
`https://${remoteService}.hitman.io/authentication/api/userchannel/ProfileService/GetProfile`,
420420
false,
421421
{
422-
id: req.query.user,
422+
id: auth.profileId,
423423
extensions: [
424424
"achievements",
425425
"friends",

tests/src/oauthToken.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ describe("oauthToken", () => {
4141

4242
const getExternalUserData = vi
4343
.spyOn(databaseHandler, "getExternalUserData")
44-
.mockResolvedValue("")
44+
.mockResolvedValue(pId)
4545
const loadUserData = vi
4646
.spyOn(databaseHandler, "loadUserData")
4747
// @ts-expect-error This is okay.

0 commit comments

Comments
 (0)