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"
2020import { log , LogLevel } from "./loggingInterop"
2121import { userAuths } from "./officialServerAuth"
2222import {
@@ -27,7 +27,18 @@ import {
2727 STEAM_NAMESPACE_2016 ,
2828} from "./platformEntitlements"
2929import { 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 */
64356export 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 ) {
0 commit comments