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
234 changes: 233 additions & 1 deletion 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 @@ -28,6 +28,14 @@ import {
} from "./platformEntitlements"
import { GameVersion } from "./types/types"
import { getRemoteService } from "./utils"
import { getFlag } from "./flags"
import { parseAppTicket } from "steam-appticket"
import { createHash } from "crypto"

// An in-memory cache of Steam ownership tickets to entitlements (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: Map<string, string[]> = new Map()

/**
* The base class for an entitlement strategy.
Expand All @@ -39,6 +47,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,6 +70,224 @@ 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
}
}
}

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

constructor(private readonly _appId: string) {
super()
this._appId = _appId

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,
): Promise<SteamAuthResult> {
return {
success: false,
code: 500,
error: "Backend validation not implemented",
}
}

private async _getFromSteam(
clientToken: string,
identity: string,
): Promise<SteamAuthResult> {
const ticket = parseAppTicket(Buffer.from(clientToken, "hex"))

if (!ticket?.isValid) {
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: this._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}`,
}
}

return {
success: true,
steamId: data.response.params!.steamid,
entitlements: [
ticket.appID.toString(),
...ticket.dlc.map((dlc) => dlc.appID.toString()),
],
}
} 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 hash = createHash("sha256")
.update(
clientToken.startsWith("14000000") &&
clientToken.length > 52 * 2
? clientToken.substring(52 * 2) // Skip 52 bytes to get the ownership ticket (this part can be valid for up to 21 days)
: clientToken,
)
.digest("hex")

if (STEAM_TICKET_CACHE.has(hash)) {
return STEAM_TICKET_CACHE.get(hash)!
}

const authMethod = getFlag(
"steamAuthenticationMethod",
) as SteamAuthMethod
const res = await (authMethod === "BACKEND"
? this._getFromBackend(clientToken)
: this._getFromSteam(clientToken, 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.set(hash, res.entitlements)

return res.entitlements
}
}

/**
* Provider for any game using the official servers.
*
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
59 changes: 47 additions & 12 deletions components/oauthToken.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,10 +39,12 @@ import {
EpicH1Strategy,
EpicH3Strategy,
IOIStrategy,
SteamStrategy,
SteamH1Strategy,
SteamH2Strategy,
SteamScpcStrategy,
} from "./entitlementStrategies"
import { getFlag } from "./flags"

export const JWT_SECRET = PEACOCK_DEV
? "secret"
Expand All @@ -51,6 +53,8 @@ export const JWT_SECRET = PEACOCK_DEV
export type OAuthTokenBody = {
grant_type: "external_steam" | "external_epic" | "refresh_token"
steam_userid?: string
steam_clienttoken?: string
steam_identity?: string
epic_userid?: string
access_token: string
pId?: string
Expand Down Expand Up @@ -222,17 +226,19 @@ export async function handleOAuthToken(
/*
Store user auth for all games except scpc
*/
if (!isScpc) {
const authContainer = new OfficialServerAuth(
gameVersion,
req.body.access_token,
)
const authUser = async () => {
if (!isScpc) {
const authContainer = new OfficialServerAuth(
gameVersion,
req.body.access_token,
)

log(LogLevel.DEBUG, `Setting up container with ID ${req.body.pId}.`)
log(LogLevel.DEBUG, `Setting up container with ID ${req.body.pId}.`)

userAuths.set(req.body.pId, authContainer)
userAuths.set(req.body.pId!, authContainer)

await authContainer._initiallyAuthenticate(req)
await authContainer._initiallyAuthenticate(req)
}
}

let userData = getUserData(req.body.pId, gameVersion)
Expand Down Expand Up @@ -266,6 +272,10 @@ export async function handleOAuthToken(
return new SteamScpcStrategy().get()
}

// Authenticate user with official if we're not on H3 Steam (otherwise the token will be invalidated)
if (gameVersion !== "h3" || external_platform !== "steam")
await authUser()

if (gameVersion === "h1") {
if (external_platform === "steam") {
return new SteamH1Strategy().get()
Expand All @@ -288,10 +298,35 @@ export async function handleOAuthToken(
req.body.epic_userid!,
)
} else if (external_platform === "steam") {
return await new IOIStrategy(
gameVersion,
STEAM_NAMESPACE_2021,
).get(req.body.pId!)
let ents = await new SteamStrategy(external_appid).get(
req.body.steam_clienttoken!,
req.body.steam_identity!,
req.body.steam_userid!,
)
await authUser()

if (ents.length === 0) {
if (
getFlag("steamAuthenticationMethod") === "STEAM_STRICT"
) {
log(
LogLevel.WARN,
"No entitlements returned by SteamStrategy with strict mode enabled!",
)
return []
}

log(
LogLevel.WARN,
"No entitlements returned by SteamStrategy - defaulting to official!",
)
ents = await new IOIStrategy(
gameVersion,
external_appid,
).get(req.body.pId!)
}

return ents
} else {
log(LogLevel.ERROR, "Unsupported platform.")
return []
Expand Down
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,8 @@
"random": "^5.3.0",
"semver": "^7.7.1",
"send": "^1.1.0",
"serve-static": "^2.1.0"
"serve-static": "^2.1.0",
"steam-appticket": "^2.0.1"
},
"devDependencies": {
"@eslint/compat": "^1.2.7",
Expand Down
Loading