import axios from 'axios'; import { Queue } from 'bull'; import * as admin from 'firebase-admin'; import { Repository } from 'typeorm'; import { InjectQueue } from '@nestjs/bull'; import { BadRequestException, Inject, Injectable, NotFoundException, UnauthorizedException } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { User } from './database/entities'; import SteamID from './libs/steamid/steamid'; import { CustomTokenResponse, QuestType } from './funki-id.dto'; import { FunkiSteamConfiguration } from './funki-id.module'; export type SteamPlayer = { steamid: string; personaname: string; }; export type SteamExtraData = { ownedGames: number; steamLevel: number; }; /** * THE AUTH FLOW * 1. Call getAuthUrl(), it returns a steamcommunity.com url that you should redirect your user to. * 2. After your user has logged on their account, steam will redirect your user to your frontend on "openid.return_to" url * with a bunch of queries * 3. Take these queries, call the authorize(), this should verify everything then return a Firebase custom token * 4. Use custom token to login with Firebase */ @Injectable() export class FunkiIdSteamService { constructor( @Inject('FIREBASE_AUTH') private readonly firebaseAuth: admin.auth.Auth, @InjectRepository(User, 'funki-id') private readonly userRepo: Repository, @Inject('STEAM_CONFIG') private readonly steamConfig: FunkiSteamConfiguration, @InjectQueue('validate-quest-by-type-queue') private readonly validateQueue: Queue, ) {} public getAuthUrl() { return this.getUrl(); } private async getExtraData(steamId: string): Promise { try { const steamOwnedUrl = `http://api.steampowered.com/IPlayerService/GetOwnedGames/v0001/?key=${this.steamConfig.apiKey}&steamid=${steamId}&format=json&include_played_free_games=1`; const steamLevelUrl = `https://api.steampowered.com/IPlayerService/GetSteamLevel/v1/?key=${this.steamConfig.apiKey}&steamid=${steamId}`; const [ownedResponse, steamLevelResponse] = await Promise.all([ axios.get(steamOwnedUrl), axios.get(steamLevelUrl), ]); return { ownedGames: ownedResponse?.data?.response?.game_count ?? 0, steamLevel: steamLevelResponse?.data?.response?.player_level ?? 0, }; } catch (error) { throw new BadRequestException('Server steam was busy, please try again later!'); } } public async authorize(url: string, authUser?: User): Promise { const steamId = await this.verifyLogin(url); const steamId64 = steamId.getSteamID64(); const response = await fetch( `https://api.steampowered.com/ISteamUser/GetPlayerSummaries/v0002/?key=${this.steamConfig.apiKey}&steamids=${steamId64}`, ); if (!response.ok) { throw new NotFoundException('Steam user not found'); } const result = await response.json(); const player: SteamPlayer = result.response.players[0]; const steam = await this.getExtraData(player.steamid); if (authUser) { await this.linkUserWithSteamPlayer(authUser, player, steam); await this.validateQueue.add('validate-quest-by-type-job', { funkiId: authUser.uid, type: QuestType.LINK_SOCIAL, }); return { customToken: await this.firebaseAuth.createCustomToken(authUser.uid, { steamId: authUser.steamId }), }; } const user = await this.findOrCreateUserBySteamPlayer(player, steam); const customToken = await this.firebaseAuth.createCustomToken(user.uid, { steamId: user.steamId }); await this.validateQueue.add('validate-quest-by-type-job', { funkiId: user.uid, type: QuestType.LINK_SOCIAL, }); return { customToken, }; } private async linkUserWithSteamPlayer(user: User, player: SteamPlayer, steam: Record) { if (user.steamId) { throw new BadRequestException('You cannot link a new Steam account'); } user.steamId = player.steamid; await this.userRepo.update({ uid: user.uid }, { steamId: player.steamid, steam }); } private async findOrCreateUserBySteamPlayer(player: SteamPlayer, steam: Record) { let user = await this.userRepo.findOne({ where: { steamId: player.steamid } }); if (!user) { const firebaseUser = await this.firebaseAuth.createUser({ displayName: player.personaname, }); user = await this.userRepo.save({ uid: firebaseUser.uid, steamId: player.steamid, steam }); } return user; } // Copied from https://github.com/DoctorMcKay/node-steam-signin/blob/master/index.js private getUrl() { const query = new URLSearchParams({ 'openid.claimed_id': 'http://specs.openid.net/auth/2.0/identifier_select', 'openid.identity': 'http://specs.openid.net/auth/2.0/identifier_select', 'openid.mode': 'checkid_setup', 'openid.ns': 'http://specs.openid.net/auth/2.0', 'openid.realm': this.canonicalizeRealm(this.steamConfig.redirectUrl), 'openid.return_to': `${this.steamConfig.redirectUrl}`, }); return 'https://steamcommunity.com/openid/login?' + query.toString(); } private async verifyLogin(url: string) { if (url.startsWith('/')) { // We really only care about the query string here url = 'http://example.com' + url; } const parsedUrl = new URL(url); let query = {}; const passThroughParams = ['openid.assoc_handle', 'openid.signed', 'openid.sig']; // Check the response mode const openidMode = parsedUrl.searchParams.get('openid.mode') || ''; if (openidMode != 'id_res') { throw new Error(`Response parameter openid.mode value "${openidMode}" does not match expected value "id_res"`); } for (let i = 0; i < passThroughParams.length; i++) { const param = passThroughParams[i]; if (!parsedUrl.searchParams.has(param)) { throw new Error(`No "${param}" parameter is present in the URL`); } query[param] = parsedUrl.searchParams.get(param); } const signedParams = query['openid.signed'].split(','); for (let i = 0; i < signedParams.length; i++) { const param = `openid.${signedParams[i]}`; if (!parsedUrl.searchParams.has(param)) { throw new Error(`No "${param}" parameter is present in the URL`); } query[param] = parsedUrl.searchParams.get(param); } // Verify that some important parameters are signed. Steam *should* check this, but const's be doubly sure. const requireSigned = [ 'claimed_id', // The user's SteamID. If not signed, the SteamID could be spoofed. 'return_to', // The return URL. If not signed, a login from another (malicious) site could be used. 'response_nonce', // The response nonce. If not signed, a successful login could be reused. ]; if (requireSigned.some(param => !signedParams.includes(param))) { throw new Error('A vital parameter was not signed'); } // Set these params here to avoid any potential for malicious user input overwriting them query = { ...query, 'openid.ns': 'http://specs.openid.net/auth/2.0', 'openid.mode': 'check_authentication', }; // Check openid.return_to from our query object, because it's very important that it be a signed parameter. const returnTo = query['openid.return_to']; if (!returnTo) { throw new Error('No "openid.return_to" parameter is present in the URL'); } const realm = this.canonicalizeRealm(returnTo); if (realm != this.canonicalizeRealm(this.steamConfig.redirectUrl)) { throw new Error(`Return realm "${realm}" does not match expected realm "${this.steamConfig.redirectUrl}"`); } const claimedIdMatch = (query['openid.claimed_id'] || '').match( /^https?:\/\/steamcommunity\.com\/openid\/id\/(\d+)\/?$/, ); if (!claimedIdMatch) { throw new Error('No "openid.claimed_id" parameter is present in the URL, or it doesn\'t have the correct format'); } const encodedBody = new URLSearchParams(query); const response = await fetch('https://steamcommunity.com/openid/login', { method: 'POST', body: encodedBody, headers: { 'Content-Type': 'application/x-www-form-urlencoded', }, }); if (!response.ok) { throw new Error('Steam response error'); } const result = await response.text(); const isValid = !!result.split('\n').find(line => line === 'is_valid:true'); if (!isValid) { throw new UnauthorizedException('Invalid auth'); } const steamId = new SteamID(claimedIdMatch[1]); return steamId; } private canonicalizeRealm(realm: string) { const match = realm.match(/^(https?:\/\/[^:/]+)/); if (!match) { throw new Error(`"${realm}" does not appear to be a valid realm`); } return match[1].toLowerCase(); } }