UNPKG

passport-steam-openid

Version:

Passport strategy for authenticating with steam openid without the use of 3rd party openid packages.

356 lines (355 loc) 14.2 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.SteamOpenIdStrategy = void 0; const tslib_1 = require("tslib"); const querystring_1 = tslib_1.__importDefault(require("querystring")); const passport_1 = require("passport"); const error_1 = require("./error"); const constant_1 = require("./constant"); const type_1 = require("./type"); /** * Strategy that authenticates you via steam openid without the use of any external openid libraries, * which can and are source of many vulnerabilities. * * Functionality should be similar to `passport-steam`. * * @class SteamOpenIdStrategy */ class SteamOpenIdStrategy extends passport_1.Strategy { /** * @constructor * * @param options.returnURL where steam redirects after parameters are passed * @param options.profile if set, we will fetch user's profile from steam api * @param options.apiKey api key to fetch user profile, not used if profile is false * @param options.maxNonceTimeDelay optional setting for validating nonce time delay, * this is just an extra security measure, it is not required nor recommended, but * might be extra layer of security you want to have. * @param verify optional callback, called when user is successfully authenticated */ constructor(options, verify) { super(); this.name = 'steam-openid'; this.returnURL = options.returnURL; this.profile = options.profile; this.maxNonceTimeDelay = options.maxNonceTimeDelay; if (options.profile) this.apiKey = options.apiKey; if (verify) this.verify = verify; if (options.httpClient) { this.http = options.httpClient; } else { try { // Eslint was throwing schema errors at me, so it's just excluded here // instead of the config // eslint-disable-next-line @typescript-eslint/no-var-requires const axios = require('axios'); this.http = axios.create(); } catch (e) { throw new Error('Could not import axios as the default http client, either\n' + ' - run `npm install axios`\n' + ' - implement `IAxiosLikeHttpClient` interface and pass it as `httpClient` option\n'); } } } /** * Passport handle for authentication. We handle the query, passport does rest. * * @param req Base IncommingMessage request enhanced with parsed querystring. */ authenticate(req) { return tslib_1.__awaiter(this, void 0, void 0, function* () { try { const user = yield this.handleRequest(req); if (!this.verify) { this.success(user); return; } this.verify(req, user.steamid, user, (err, user) => { if (err) { this.error(err); return; } if (!user) { this.error(new Error('No user was received from callback.')); return; } this.success(user); }); } catch (err) { if (this.isRetryableError(err)) { this.redirect(this.buildRedirectUrl()); return; } this.error(err); } }); } /** * Handles validation request. * * Can be used in a middleware, if you don't like passport. * * @param req Base IncommingMessage request enhanced with parsed querystring. * @returns * @throws {SteamOpenIdError} User related problem, such as: * - open.mode was not correct * - query did not was pass validation * - steam rejected this query * - steamid is invalid * @throws {Error} Non-recoverable errors, such as query object missing. */ handleRequest(req) { return tslib_1.__awaiter(this, void 0, void 0, function* () { const query = this.getQuery(req); if (!this.hasAuthQuery(query)) { throw new error_1.SteamOpenIdError('openid.mode is incorrect.', type_1.SteamOpenIdErrorType.InvalidMode); } if (!this.isQueryValid(query)) { throw new error_1.SteamOpenIdError('Supplied query is invalid.', type_1.SteamOpenIdErrorType.InvalidQuery); } if (this.hasNonceExpired(query)) { throw new error_1.SteamOpenIdError('Nonce time delay was too big.', type_1.SteamOpenIdErrorType.NonceExpired); } const valid = yield this.validateAgainstSteam(query); if (!valid) { throw new error_1.SteamOpenIdError('Failed to validate query against steam.', type_1.SteamOpenIdErrorType.Unauthorized); } const steamId = this.getSteamId(query); return yield this.getUser(steamId); }); } /** * Check if nonce date has expired against current delay setting, * if no setting was set, then it is considered as not expired. * * @param nonceDate date when nonce was created * @returns true, if nonce has expired and error should be thrown */ hasNonceExpired(query) { if (typeof this.maxNonceTimeDelay == 'undefined') { return false; } const nonceDate = new Date(query['openid.response_nonce'].slice(0, 20)); const nonceSeconds = Math.floor(nonceDate.getTime() / 1000); const nowSeconds = Math.floor(Date.now() / 1000); return nowSeconds - nonceSeconds > this.maxNonceTimeDelay; } /** * Checks if error is retryable, * meaning user gets redirected to steam openid page. * * @param err from catch clause * @returns true, if error should be retried * @returns false, if error is not retriable * and should be handled by the app. */ isRetryableError(err) { return (err instanceof error_1.SteamOpenIdError && err.code == type_1.SteamOpenIdErrorType.InvalidMode); } /** * Retrieves query parameter from req object and checks if it is an object. * * @param req Base IncommingMessage request enhanced with parsed querystring. * @returns query from said request * @throws Error if query cannot be found, non-recoverable error. */ getQuery(req) { if (!req['query'] || typeof req['query'] != 'object') { throw new Error('Query was not found on request object.'); } return req['query']; } /** * Checks if `mode` field from query is correct and thus authentication can begin * * @param query original query user submitted * @returns true, if mode is correct, equal to `id_res` * @returns false, if mode is incorrect */ hasAuthQuery(query) { return !!query['openid.mode'] && query['openid.mode'] == 'id_res'; } /** * Builds a redirect url for user that is about to authenticate * * @returns redirect url built with proper parameters */ buildRedirectUrl() { const openIdParams = { 'openid.mode': 'checkid_setup', 'openid.ns': constant_1.VALID_NONCE, 'openid.identity': constant_1.VALID_ID_SELECT, 'openid.claimed_id': constant_1.VALID_ID_SELECT, 'openid.return_to': this.returnURL, }; return `${constant_1.VALID_OPENID_ENDPOINT}?${querystring_1.default.stringify(openIdParams)}`; } /** * Validates user submitted query, if it contains correct parameters. * No excess parameters can be used. * * @param query original query user submitted * @returns true, query contains correct parameters * @returns false, query contains incorrect parameters */ isQueryValid(query) { for (const key of constant_1.OPENID_QUERY_PROPS) { // Every prop has to be present if (!query[key]) { return false; } } for (const key of Object.keys(query)) { // Do not allow any extra properties if (!constant_1.OPENID_QUERY_PROPS.includes(key)) { return false; } } if (query['openid.ns'] !== constant_1.VALID_NONCE) return false; if (query['openid.op_endpoint'] !== constant_1.VALID_OPENID_ENDPOINT) return false; if (query['openid.claimed_id'] !== query['openid.identity']) return false; if (!this.isValidIdentity(query['openid.claimed_id'])) return false; if (query['openid.assoc_handle'] !== constant_1.VALID_ASSOC_HANDLE) return false; if (query['openid.signed'] !== constant_1.VALID_SIGNED_FIELD) return false; return query['openid.return_to'] == this.returnURL; } /** * Checks if identity starts with correct link. * * @param identity from querystring * @returns true, if identity is a string and starts with correct endpoint * @return false, if above criteria was violated */ isValidIdentity(identity) { return (typeof identity == 'string' && !!identity.match(/^https:\/\/steamcommunity\.com\/openid\/id\/(7656119[0-9]{10})\/?$/)); } /** * Query trusted steam endpoint to validate supplied query. * * @param query original query user submitted * @returns true, if positive response was received * @returns false, if request failed, status is incorrect or data signals invalid */ validateAgainstSteam(query) { return this.http .post(constant_1.VALID_OPENID_ENDPOINT, this.getOpenIdValidationRequestBody(query), { maxRedirects: 0, headers: { 'Content-Type': 'application/x-www-form-urlencoded', Origin: 'https://steamcommunity.com', Referer: 'https://steamcommunity.com', }, }) .then(({ data, status }) => { if (status !== 200) { return false; } return this.isSteamResponseValid(data); }) .catch(() => { return false; }); } /** * Clones query from authentication request, changes mode and stringifies to form data. * @param query original query user submitted * @returns stringified form data with changed mode */ getOpenIdValidationRequestBody(query) { const data = Object.assign({}, query); data['openid.mode'] = 'check_authentication'; return querystring_1.default.stringify(data); } /** * Validates response from steam to see if query is correct. * * @param response response received from steama * @returns true, if data was in correct format and signals valid query * @return false, if data was corrupted or invalid query was signaled */ isSteamResponseValid(response) { if (typeof response != 'string') return false; const match = response.match(/^ns:(.+)\nis_valid:(.+)\n$/); if (!match) return false; if (match[1] != constant_1.VALID_NONCE) return false; return match[2] == 'true'; } /** * Parses steamId from `claimed_id` field, which is what openid 2.0 uses. * * @param query original query user submitted * @returns parsed steamId */ getSteamId(query) { return query['openid.claimed_id'] .replace(`${constant_1.VALID_IDENTITY_ENDPOINT}/`, '') .replace('/', ''); // Incase steam starts sending links ending with / } /** * Abstract method for getting user that has been authenticated. * You can implement fetching user from steamid and thus validating even more, * or if you are satisified with just steamId, you can return it as an object * and continue without need of an steam api key. * * @param steamId steamId parsed from `claimed_id` * @return generic that was chosen by child class */ getUser(steamId) { return tslib_1.__awaiter(this, void 0, void 0, function* () { // Kind of hacky way to force the generic, but will do for now. if (this.profile) { return this.fetchPlayerSummary(steamId); } return { steamid: steamId }; }); } /** * Fetches profile data for authenticated user. * Validates the steamId even more. * * @param steamId parsed steamId from `claimed_id` * @returns profile belonging to said steamId * * @throws {Error} if malformed response was received * @throws {AxiosError} if status was not 200 * @throws {SteamOpenIdError} if profile was not found */ fetchPlayerSummary(steamId) { var _a; return tslib_1.__awaiter(this, void 0, void 0, function* () { const summaryQuery = { steamids: steamId, key: this.apiKey, }; const { data } = yield this.http.get(`${constant_1.PLAYER_SUMMARY_URL}/?${querystring_1.default.stringify(summaryQuery)}`); if (!Array.isArray((_a = data === null || data === void 0 ? void 0 : data.response) === null || _a === void 0 ? void 0 : _a.players)) { throw new Error('Malformed response from steam.'); } const user = data.response.players[0]; if (!user) { throw new error_1.SteamOpenIdError('Profile was not found on steam.', type_1.SteamOpenIdErrorType.InvalidSteamId); } if (user.steamid != steamId) { throw new error_1.SteamOpenIdError('API returned invalid user.', type_1.SteamOpenIdErrorType.InvalidSteamId); } return user; }); } } exports.SteamOpenIdStrategy = SteamOpenIdStrategy;