Skip to content

Commit c407faa

Browse files
feat: allow for authenticating Steam users without official (#679)
As title. Validates using the app ticket.
2 parents 0ccbe4d + 2a77183 commit c407faa

5 files changed

Lines changed: 470 additions & 20 deletions

File tree

components/entitlementStrategies.ts

Lines changed: 298 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
* along with this program. If not, see <https://www.gnu.org/licenses/>.
1717
*/
1818

19-
import { AxiosError, AxiosResponse } from "axios"
19+
import axios, { AxiosError, AxiosResponse } from "axios"
2020
import { log, LogLevel } from "./loggingInterop"
2121
import { userAuths } from "./officialServerAuth"
2222
import {
@@ -27,7 +27,18 @@ import {
2727
STEAM_NAMESPACE_2016,
2828
} from "./platformEntitlements"
2929
import { GameVersion } from "./types/types"
30-
import { getRemoteService } from "./utils"
30+
import {
31+
AppTicket,
32+
getRemoteService,
33+
parseAppTicket,
34+
PEACOCKVERSTRING,
35+
} from "./utils"
36+
import { getFlag } from "./flags"
37+
38+
// An in-memory cache of valid Steam ownership ticket hashes (they're valid for up to 21 days)
39+
// For most users, this won't provide any benefit since they'll be restarting Peacock often,
40+
// but this is more here for those running it 24/7 on a server somewhere.
41+
const STEAM_TICKET_CACHE: Set<string> = new Set()
3142

3243
/**
3344
* The base class for an entitlement strategy.
@@ -39,6 +50,12 @@ abstract class EntitlementStrategy {
3950
accessToken: string,
4051
userId: string,
4152
): string[] | Promise<string[]>
53+
54+
abstract get(
55+
clientToken: string,
56+
identity: string,
57+
steamId: string,
58+
): string[] | Promise<string[]>
4259
}
4360

4461
/**
@@ -56,20 +73,293 @@ export class EpicH3Strategy extends EntitlementStrategy {
5673
}
5774
}
5875

76+
/**
77+
* Provider for any Steam-based game using the ISteamUserAuth API.
78+
*
79+
* @internal
80+
*/
81+
type SteamAuthMethod = "OFFICIAL" | "BACKEND" | "STEAM" | "STEAM_STRICT"
82+
type SteamAuthResult =
83+
| {
84+
success: true
85+
steamId: string
86+
entitlements: string[]
87+
}
88+
| {
89+
success: false
90+
code: number
91+
error: string
92+
}
93+
type SteamAuthResponse = {
94+
response: {
95+
error?: {
96+
errorcode: number
97+
errordesc: string
98+
}
99+
params?: {
100+
result: string
101+
steamid: string
102+
ownersteamid: string
103+
vacbanned: boolean
104+
publisherbanned: boolean
105+
}
106+
}
107+
}
108+
type SteamAuthBackendResponse =
109+
| {
110+
success: true
111+
steam_id: string
112+
entitlements: string[]
113+
}
114+
| {
115+
success: false
116+
error: string
117+
}
118+
119+
export class SteamStrategy extends EntitlementStrategy {
120+
private readonly _apiKey: string = getFlag("steamApiKey") as SteamAuthMethod
121+
public readonly isValid: boolean = false
122+
123+
constructor() {
124+
super()
125+
126+
const method = getFlag("steamAuthenticationMethod") as SteamAuthMethod
127+
128+
switch (method) {
129+
case "BACKEND": {
130+
const host = getFlag("leaderboardsHost") as string
131+
132+
if (!host) {
133+
log(
134+
LogLevel.WARN,
135+
"steamAuthenticationMethod is set to 'BACKEND' but 'leaderboardsHost' is null or empty - using official!",
136+
"SteamStrategy",
137+
)
138+
break
139+
}
140+
141+
this.isValid = true
142+
break
143+
}
144+
case "STEAM":
145+
case "STEAM_STRICT": {
146+
if (!this._apiKey) {
147+
log(
148+
LogLevel.WARN,
149+
`steamAuthenticationMethod is set to '${method}' but 'steamApiKey' is null or empty${method !== "STEAM_STRICT" ? " - using official" : ""}!`,
150+
"SteamStrategy",
151+
)
152+
break
153+
}
154+
155+
this.isValid = true
156+
break
157+
}
158+
case "OFFICIAL":
159+
break
160+
}
161+
}
162+
163+
private async _getFromBackend(
164+
clientToken: string,
165+
identity: string,
166+
): Promise<SteamAuthResult> {
167+
try {
168+
const host = getFlag("leaderboardsHost") as string
169+
const resp = await axios.post(
170+
`${host}/peacock/steam/validate_ticket`,
171+
{
172+
ticket: clientToken,
173+
identity,
174+
},
175+
{
176+
headers: {
177+
"Peacock-Version": PEACOCKVERSTRING,
178+
},
179+
validateStatus: (status) =>
180+
status === 400 || (status >= 200 && status < 300),
181+
},
182+
)
183+
184+
if (resp.status !== 200 && resp.status !== 400) {
185+
return {
186+
success: false,
187+
code: resp.status,
188+
error: `${resp.statusText}`,
189+
}
190+
}
191+
192+
const data = resp.data as SteamAuthBackendResponse
193+
194+
if (!data.success) {
195+
return {
196+
success: false,
197+
code: resp.status,
198+
error: data.error,
199+
}
200+
}
201+
202+
return {
203+
success: true,
204+
steamId: data.steam_id,
205+
entitlements: data.entitlements,
206+
}
207+
} catch (error) {
208+
if (error instanceof AxiosError) {
209+
return {
210+
success: false,
211+
code: error.response?.status ?? 400,
212+
error: `${error.response?.statusText}`,
213+
}
214+
} else {
215+
return {
216+
success: false,
217+
code: 400,
218+
error: `${error}`,
219+
}
220+
}
221+
}
222+
}
223+
224+
private async _getFromSteam(
225+
clientToken: string,
226+
ticket: AppTicket,
227+
identity: string,
228+
): Promise<SteamAuthResult> {
229+
// We already check this before calling, but it's just for sanity.
230+
if (!ticket?.valid) {
231+
return {
232+
success: false,
233+
code: 400,
234+
error: "Invalid app ticket.",
235+
}
236+
}
237+
238+
try {
239+
const resp = await axios(
240+
"https://api.steampowered.com/ISteamUserAuth/AuthenticateUserTicket/v1",
241+
{
242+
params: {
243+
key: this._apiKey,
244+
appid: ticket.appId,
245+
ticket: clientToken,
246+
identity,
247+
},
248+
},
249+
)
250+
251+
if (resp.status !== 200) {
252+
return {
253+
success: false,
254+
code: resp.status,
255+
error: `${resp.statusText}`,
256+
}
257+
}
258+
259+
const data = resp.data as SteamAuthResponse
260+
261+
if (data.response.error) {
262+
return {
263+
success: false,
264+
code: data.response.error.errorcode,
265+
error: `${data.response.error.errordesc}`,
266+
}
267+
}
268+
269+
if (data.response.params!.result !== "OK") {
270+
return {
271+
success: false,
272+
code: 200,
273+
error: `${data.response.params!.result}`,
274+
}
275+
}
276+
277+
ticket.dlc.unshift(ticket.appId)
278+
return {
279+
success: true,
280+
steamId: data.response.params!.steamid,
281+
entitlements: ticket.dlc,
282+
}
283+
} catch (error) {
284+
if (error instanceof AxiosError) {
285+
return {
286+
success: false,
287+
code: error.response?.status ?? 400,
288+
error: `${error.response?.statusText}`,
289+
}
290+
} else {
291+
return {
292+
success: false,
293+
code: 400,
294+
error: `${error}`,
295+
}
296+
}
297+
}
298+
}
299+
300+
// @ts-expect-error There are two functions we can overload
301+
override async get(
302+
clientToken: string,
303+
identity: string,
304+
steamId: string,
305+
): Promise<string[]> {
306+
if (!this.isValid) return []
307+
308+
const ticket = parseAppTicket(Buffer.from(clientToken, "hex"))
309+
310+
if (!ticket?.valid) {
311+
log(LogLevel.WARN, "Invalid ticket.", "SteamStrategy")
312+
return []
313+
}
314+
315+
if (STEAM_TICKET_CACHE.has(ticket.hash)) {
316+
ticket.dlc.unshift(ticket.appId)
317+
return ticket.dlc
318+
}
319+
320+
const authMethod = getFlag(
321+
"steamAuthenticationMethod",
322+
) as SteamAuthMethod
323+
const res = await (authMethod === "BACKEND"
324+
? this._getFromBackend(clientToken, identity)
325+
: this._getFromSteam(clientToken, ticket, identity))
326+
327+
if (!res.success) {
328+
log(
329+
LogLevel.WARN,
330+
`Failed to get entitlements from ${authMethod.split("_")[0]}. Code: ${res.code}, Error: ${res.error} `,
331+
"SteamStrategy",
332+
)
333+
return []
334+
}
335+
336+
if (res.steamId !== steamId) {
337+
log(
338+
LogLevel.WARN,
339+
`Encountered mismatched SteamID when validating authentication token! Expected: ${steamId}, Got: ${res.steamId}`,
340+
"SteamStrategy",
341+
)
342+
return []
343+
}
344+
345+
STEAM_TICKET_CACHE.add(ticket.hash)
346+
347+
return res.entitlements
348+
}
349+
}
350+
59351
/**
60352
* Provider for any game using the official servers.
61353
*
62354
* @internal
63355
*/
64356
export class IOIStrategy extends EntitlementStrategy {
65357
private readonly _remoteService: string
358+
private readonly _issuerId: string
66359

67-
constructor(
68-
gameVersion: GameVersion,
69-
private readonly issuerId: string,
70-
) {
360+
constructor(gameVersion: GameVersion, issuerId: string) {
71361
super()
72-
this.issuerId = issuerId
362+
this._issuerId = issuerId
73363
this._remoteService = getRemoteService(gameVersion)!
74364
}
75365

@@ -88,7 +378,7 @@ export class IOIStrategy extends EntitlementStrategy {
88378
`https://${this._remoteService}.hitman.io/authentication/api/userchannel/ProfileService/GetPlatformEntitlements`,
89379
false,
90380
{
91-
issuerId: this.issuerId,
381+
issuerId: this._issuerId,
92382
},
93383
)
94384
} catch (error) {

components/flags.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,26 @@ export const defaultFlags: Flags = {
130130
possibleValues: ["SAVEASREQUESTED", "ONLINE", "OFFLINE"],
131131
default: "SAVEASREQUESTED",
132132
},
133+
steamAuthenticationMethod: {
134+
category: "Services",
135+
title: "steamAuthenticationMethod",
136+
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.",
137+
possibleValues: [
138+
"OFFICIAL",
139+
"BACKEND",
140+
"STEAM",
141+
"STEAM_STRICT",
142+
],
143+
default: "BACKEND",
144+
showIngame: false,
145+
},
146+
steamApiKey: {
147+
category: "Services",
148+
title: "Steam API Key",
149+
desc: "The Steam API key to use when 'steamAuthenticationMethod' is set to 'STEAM' or 'STEAM_STRICT'.",
150+
default: "",
151+
showIngame: false,
152+
},
133153
liveSplit: {
134154
category: "Splitter",
135155
title: "LiveSplit",

0 commit comments

Comments
 (0)