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
JavaScript
"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;