Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
306 changes: 298 additions & 8 deletions components/entitlementStrategies.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/

import { AxiosError, AxiosResponse } from "axios"
import axios, { AxiosError, AxiosResponse } from "axios"
import { log, LogLevel } from "./loggingInterop"
import { userAuths } from "./officialServerAuth"
import {
Expand All @@ -27,7 +27,18 @@ import {
STEAM_NAMESPACE_2016,
} from "./platformEntitlements"
import { GameVersion } from "./types/types"
import { getRemoteService } from "./utils"
import {
AppTicket,
getRemoteService,
parseAppTicket,
PEACOCKVERSTRING,
} from "./utils"
import { getFlag } from "./flags"

// An in-memory cache of valid Steam ownership ticket hashes (they're valid for up to 21 days)
// For most users, this won't provide any benefit since they'll be restarting Peacock often,
// but this is more here for those running it 24/7 on a server somewhere.
const STEAM_TICKET_CACHE: Set<string> = new Set()

/**
* The base class for an entitlement strategy.
Expand All @@ -39,6 +50,12 @@ abstract class EntitlementStrategy {
accessToken: string,
userId: string,
): string[] | Promise<string[]>

abstract get(
clientToken: string,
identity: string,
steamId: string,
): string[] | Promise<string[]>
}

/**
Expand All @@ -56,20 +73,293 @@ export class EpicH3Strategy extends EntitlementStrategy {
}
}

/**
* Provider for any Steam-based game using the ISteamUserAuth API.
*
* @internal
*/
type SteamAuthMethod = "OFFICIAL" | "BACKEND" | "STEAM" | "STEAM_STRICT"
type SteamAuthResult =
| {
success: true
steamId: string
entitlements: string[]
}
| {
success: false
code: number
error: string
}
type SteamAuthResponse = {
response: {
error?: {
errorcode: number
errordesc: string
}
params?: {
result: string
steamid: string
ownersteamid: string
vacbanned: boolean
publisherbanned: boolean
}
}
}
type SteamAuthBackendResponse =
| {
success: true
steam_id: string
entitlements: string[]
}
| {
success: false
error: string
}

export class SteamStrategy extends EntitlementStrategy {
private readonly _apiKey: string = getFlag("steamApiKey") as SteamAuthMethod
public readonly isValid: boolean = false

constructor() {
super()

const method = getFlag("steamAuthenticationMethod") as SteamAuthMethod

switch (method) {
case "BACKEND": {
const host = getFlag("leaderboardsHost") as string

if (!host) {
log(
LogLevel.WARN,
"steamAuthenticationMethod is set to 'BACKEND' but 'leaderboardsHost' is null or empty - using official!",
"SteamStrategy",
)
break
}

this.isValid = true
break
}
case "STEAM":
case "STEAM_STRICT": {
if (!this._apiKey) {
log(
LogLevel.WARN,
`steamAuthenticationMethod is set to '${method}' but 'steamApiKey' is null or empty${method !== "STEAM_STRICT" ? " - using official" : ""}!`,
"SteamStrategy",
)
break
}

this.isValid = true
break
}
case "OFFICIAL":
break
}
}

private async _getFromBackend(
clientToken: string,
identity: string,
): Promise<SteamAuthResult> {
try {
const host = getFlag("leaderboardsHost") as string
const resp = await axios.post(
`${host}/peacock/steam/validate_ticket`,
{
ticket: clientToken,
identity,
},
{
headers: {
"Peacock-Version": PEACOCKVERSTRING,
},
validateStatus: (status) =>
status === 400 || (status >= 200 && status < 300),
},
)

if (resp.status !== 200 && resp.status !== 400) {
return {
success: false,
code: resp.status,
error: `${resp.statusText}`,
}
}

const data = resp.data as SteamAuthBackendResponse

if (!data.success) {
return {
success: false,
code: resp.status,
error: data.error,
}
}

return {
success: true,
steamId: data.steam_id,
entitlements: data.entitlements,
}
} catch (error) {
if (error instanceof AxiosError) {
return {
success: false,
code: error.response?.status ?? 400,
error: `${error.response?.statusText}`,
}
} else {
return {
success: false,
code: 400,
error: `${error}`,
}
}
}
}

private async _getFromSteam(
clientToken: string,
ticket: AppTicket,
identity: string,
): Promise<SteamAuthResult> {
// We already check this before calling, but it's just for sanity.
if (!ticket?.valid) {
return {
success: false,
code: 400,
error: "Invalid app ticket.",
}
}

try {
const resp = await axios(
"https://api.steampowered.com/ISteamUserAuth/AuthenticateUserTicket/v1",
{
params: {
key: this._apiKey,
appid: ticket.appId,
ticket: clientToken,
identity,
},
},
)

if (resp.status !== 200) {
return {
success: false,
code: resp.status,
error: `${resp.statusText}`,
}
}

const data = resp.data as SteamAuthResponse

if (data.response.error) {
return {
success: false,
code: data.response.error.errorcode,
error: `${data.response.error.errordesc}`,
}
}

if (data.response.params!.result !== "OK") {
return {
success: false,
code: 200,
error: `${data.response.params!.result}`,
}
}

ticket.dlc.unshift(ticket.appId)
return {
success: true,
steamId: data.response.params!.steamid,
entitlements: ticket.dlc,
}
} catch (error) {
if (error instanceof AxiosError) {
return {
success: false,
code: error.response?.status ?? 400,
error: `${error.response?.statusText}`,
}
} else {
return {
success: false,
code: 400,
error: `${error}`,
}
}
}
}

// @ts-expect-error There are two functions we can overload
override async get(
clientToken: string,
identity: string,
steamId: string,
): Promise<string[]> {
if (!this.isValid) return []

const ticket = parseAppTicket(Buffer.from(clientToken, "hex"))

if (!ticket?.valid) {
log(LogLevel.WARN, "Invalid ticket.", "SteamStrategy")
return []
}

if (STEAM_TICKET_CACHE.has(ticket.hash)) {
ticket.dlc.unshift(ticket.appId)
return ticket.dlc
}

const authMethod = getFlag(
"steamAuthenticationMethod",
) as SteamAuthMethod
const res = await (authMethod === "BACKEND"
? this._getFromBackend(clientToken, identity)
: this._getFromSteam(clientToken, ticket, identity))

if (!res.success) {
log(
LogLevel.WARN,
`Failed to get entitlements from ${authMethod.split("_")[0]}. Code: ${res.code}, Error: ${res.error} `,
"SteamStrategy",
)
return []
}

if (res.steamId !== steamId) {
log(
LogLevel.WARN,
`Encountered mismatched SteamID when validating authentication token! Expected: ${steamId}, Got: ${res.steamId}`,
"SteamStrategy",
)
return []
}

STEAM_TICKET_CACHE.add(ticket.hash)

return res.entitlements
}
}

/**
* Provider for any game using the official servers.
*
* @internal
*/
export class IOIStrategy extends EntitlementStrategy {
private readonly _remoteService: string
private readonly _issuerId: string

constructor(
gameVersion: GameVersion,
private readonly issuerId: string,
) {
constructor(gameVersion: GameVersion, issuerId: string) {
super()
this.issuerId = issuerId
this._issuerId = issuerId
this._remoteService = getRemoteService(gameVersion)!
}

Expand All @@ -88,7 +378,7 @@ export class IOIStrategy extends EntitlementStrategy {
`https://${this._remoteService}.hitman.io/authentication/api/userchannel/ProfileService/GetPlatformEntitlements`,
false,
{
issuerId: this.issuerId,
issuerId: this._issuerId,
},
)
} catch (error) {
Expand Down
20 changes: 20 additions & 0 deletions components/flags.ts
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,26 @@ export const defaultFlags: Flags = {
possibleValues: ["SAVEASREQUESTED", "ONLINE", "OFFLINE"],
default: "SAVEASREQUESTED",
},
steamAuthenticationMethod: {
category: "Services",
title: "steamAuthenticationMethod",
desc: "How users connecting via Steam should be authenticated. OFFICIAL = Official Servers, BACKEND = Using a separate backend server (uses leaderboardsHost), STEAM = Issues requests to Steam directly from Peacock, requires 'steamApiKey' to be set, STEAM_STRICT = Same as Steam, but will never fallback to official. OFFICIAL is used as a fallback if other methods fail.",
possibleValues: [
"OFFICIAL",
"BACKEND",
"STEAM",
"STEAM_STRICT",
],
default: "OFFICIAL", // TODO: Change this to BACKEND when ready.
showIngame: false,
},
steamApiKey: {
category: "Services",
title: "Steam API Key",
desc: "The Steam API key to use when 'steamAuthenticationMethod' is set to 'STEAM' or 'STEAM_STRICT'.",
default: "",
showIngame: false,
},
liveSplit: {
category: "Splitter",
title: "LiveSplit",
Expand Down
Loading
Loading