Skip to content

Commit

Permalink
feat: validate nonce time (#17)
Browse files Browse the repository at this point in the history
  • Loading branch information
danocmx authored Jul 15, 2024
2 parents 63125ce + d8a9f5a commit c77de86
Show file tree
Hide file tree
Showing 4 changed files with 136 additions and 11 deletions.
37 changes: 36 additions & 1 deletion src/strategy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,12 +66,23 @@ export class SteamOpenIdStrategy<
*/
protected verify?: VerifyCallback<TUser>;

/**
* Optional setting for validating nonce time delay,
* in seconds.
*
* Measures time between nonce creation date and verification.
*/
protected maxNonceTimeDelay: number | undefined;

/**
* @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: TOptions, verify?: VerifyCallback<TUser>) {
Expand All @@ -81,6 +92,7 @@ export class SteamOpenIdStrategy<
this.axios = axios.create();
this.returnURL = options.returnURL;
this.profile = options.profile;
this.maxNonceTimeDelay = options.maxNonceTimeDelay;
if (options.profile) this.apiKey = options.apiKey;
if (verify) this.verify = verify;
}
Expand Down Expand Up @@ -151,7 +163,12 @@ export class SteamOpenIdStrategy<
);
}

// TODO: validate nonce time
if (this.hasNonceExpired(query)) {
throw new SteamOpenIdError(
'Nonce time delay was too big.',
SteamOpenIdErrorType.NonceExpired,
);
}

const valid = await this.validateAgainstSteam(query);
if (!valid) {
Expand All @@ -165,6 +182,24 @@ export class SteamOpenIdStrategy<
return await 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
*/
protected hasNonceExpired(query: SteamOpenIdQuery): boolean {
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.
Expand Down
14 changes: 14 additions & 0 deletions src/type.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,16 @@ import { DoneCallback } from 'passport';

export type BaseSteamOpenIdStrategyOptions = {
returnURL: string;
/**
* Maximum time delay between the nonce creation and the nonce verification,
* in seconds.
*
* nonce includes a timestamp we can validate against the current time,
* while the steam server validates this as well, you might want to
* set maximum number of seconds between the nonce creation and the nonce
* verification.
*/
maxNonceTimeDelay?: number;
};

export type SteamOpenIdStrategyOptionsWithProfile = {
Expand Down Expand Up @@ -39,6 +49,10 @@ export enum SteamOpenIdErrorType {
* SteamId is not valid.
*/
InvalidSteamId = 3,
/**
* Nonce has expired.
*/
NonceExpired = 4,
}

/** When profile is not used, we just send a steamid. */
Expand Down
24 changes: 15 additions & 9 deletions test/setup/data.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,18 @@
import { VALID_NONCE, VALID_OPENID_ENDPOINT } from '../../src';
import {
SteamOpenIdQuery,
VALID_NONCE,
VALID_OPENID_ENDPOINT,
} from '../../src';

export const RETURN_URL = '/auth/steam';

export const getISODate = (date: Date) => date.toISOString().split('.')[0] + 'Z';

export const query: {
properties: Record<string, string>;
get(): Record<string, string>;
change(change: Record<string, string>): Record<string, string>;
remove(property: string): Record<string, string>;
properties: SteamOpenIdQuery;
get(): SteamOpenIdQuery;
change(change: Partial<SteamOpenIdQuery>): SteamOpenIdQuery;
remove(property: keyof SteamOpenIdQuery): SteamOpenIdQuery;
} = {
/**
* Valid query properties
Expand All @@ -18,22 +24,22 @@ export const query: {
'openid.claimed_id': `https://steamcommunity.com/openid/id/76561197960435530`,
'openid.return_to': RETURN_URL,
'openid.op_endpoint': VALID_OPENID_ENDPOINT,
'openid.response_nonce': `${new Date().toJSON()}8df86bac92ad1addaf3735a5aabdc6e2a7`,
'openid.response_nonce': `${getISODate(new Date())}8df86bac92ad1addaf3735a5aabdc6e2a7`,
'openid.assoc_handle': '1234567890',
'openid.signed':
'signed,op_endpoint,claimed_id,identity,return_to,response_nonce,assoc_handle',
'openid.sig': 'dc6e2a79de2c6aceac495ad5f4c6b6e0bfe30',
},

get() {
get(): SteamOpenIdQuery {
return { ...this.properties };
},

change(change: Record<string, string>) {
change(change: Partial<SteamOpenIdQuery>): SteamOpenIdQuery {
return { ...this.get(), ...change };
},

remove(property: string) {
remove(property: keyof SteamOpenIdQuery): SteamOpenIdQuery {
const properties = this.get();
delete properties[property];
return properties;
Expand Down
72 changes: 71 additions & 1 deletion test/strategy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import {
VALID_OPENID_ENDPOINT,
PLAYER_SUMMARY_URL,
} from '../src';
import { RETURN_URL, query } from './setup/data';
import { RETURN_URL, getISODate, query } from './setup/data';

chai.use(chaiAsPromised);

Expand Down Expand Up @@ -214,12 +214,14 @@ describe('SteamOpenIdStrategy Unit Test', () => {
let getQueryStub: sinon.SinonStub;
let hasAuthQueryStub: sinon.SinonStub;
let isQueryValidStub: sinon.SinonStub;
let hasNonceExpiredStub: sinon.SinonStub;
let validateAgainstSteamStub: sinon.SinonStub;

beforeEach(() => {
getQueryStub = sinon.stub(strategy as any, 'getQuery').returns(query);
hasAuthQueryStub = sinon.stub(strategy as any, 'hasAuthQuery');
isQueryValidStub = sinon.stub(strategy as any, 'isQueryValid');
hasNonceExpiredStub = sinon.stub(strategy as any, 'hasNonceExpired');
validateAgainstSteamStub = sinon.stub(
strategy as any,
'validateAgainstSteam',
Expand All @@ -237,6 +239,7 @@ describe('SteamOpenIdStrategy Unit Test', () => {
hasAuthQueryStub.returns(true);
isQueryValidStub.returns(true);
validateAgainstSteamStub.resolves(true);
hasNonceExpiredStub.returns(false);

const steamid = '76561197960435530';
const user = { steamid: '76561197960435530' };
Expand All @@ -253,6 +256,8 @@ describe('SteamOpenIdStrategy Unit Test', () => {
expect(getUserStub.calledWithExactly(steamid)).equal(true);
expect(isQueryValidStub.callCount).equal(1);
expect(isQueryValidStub.calledWithExactly(query)).equal(true);
expect(hasNonceExpiredStub.callCount).equal(1);
expect(hasNonceExpiredStub.calledWithExactly(query)).equal(true);
expect(validateAgainstSteamStub.callCount).equal(1);
expect(validateAgainstSteamStub.calledWithExactly(query)).equal(true);
});
Expand All @@ -269,6 +274,9 @@ describe('SteamOpenIdStrategy Unit Test', () => {

expect(err).to.be.instanceOf(SteamOpenIdError);
expect(err).to.have.property('code', SteamOpenIdErrorType.InvalidMode);
expect(isQueryValidStub.callCount).equal(0);
expect(hasNonceExpiredStub.callCount).equal(0);
expect(validateAgainstSteamStub.callCount).equal(0);
});

it('Query is invalid', async () => {
Expand All @@ -286,11 +294,35 @@ describe('SteamOpenIdStrategy Unit Test', () => {
expect(err).to.have.property('code', SteamOpenIdErrorType.InvalidQuery);
expect(isQueryValidStub.callCount).equal(1);
expect(isQueryValidStub.calledWithExactly(query)).equal(true);
expect(hasNonceExpiredStub.callCount).equal(0);
expect(validateAgainstSteamStub.callCount).equal(0);
});

it('Nonce has expired', async () => {
hasAuthQueryStub.returns(true);
isQueryValidStub.returns(true);
hasNonceExpiredStub.returns(true);

let err: any;
try {
await strategy.handleRequest(request);
} catch (e) {
err = e;
}

expect(err).to.be.instanceOf(SteamOpenIdError);
expect(err).to.have.property('code', SteamOpenIdErrorType.NonceExpired);
expect(isQueryValidStub.callCount).equal(1);
expect(isQueryValidStub.calledWithExactly(query)).equal(true);
expect(hasNonceExpiredStub.callCount).equal(1);
expect(hasNonceExpiredStub.calledWithExactly(query)).equal(true);
expect(validateAgainstSteamStub.callCount).equal(0);
});

it('Steam rejects this authentication request', async () => {
hasAuthQueryStub.returns(true);
isQueryValidStub.returns(true);
hasNonceExpiredStub.returns(false);
validateAgainstSteamStub.resolves(false);

let err: any;
Expand All @@ -304,6 +336,8 @@ describe('SteamOpenIdStrategy Unit Test', () => {
expect(err).to.have.property('code', SteamOpenIdErrorType.Unauthorized);
expect(isQueryValidStub.callCount).equal(1);
expect(isQueryValidStub.calledWithExactly(query)).equal(true);
expect(hasNonceExpiredStub.callCount).equal(1);
expect(hasNonceExpiredStub.calledWithExactly(query)).equal(true);
expect(validateAgainstSteamStub.callCount).equal(1);
expect(validateAgainstSteamStub.calledWithExactly(query)).equal(true);
});
Expand Down Expand Up @@ -722,4 +756,40 @@ describe('SteamOpenIdStrategy Unit Test', () => {
expect(err).to.have.property('code', SteamOpenIdErrorType.InvalidSteamId);
});
});

describe('hasNonceExpired', () => {
const HOUR = 1000 * 60 * 60;

it('No setting set, could not expire', () => {
expect(strategy['hasNonceExpired'](query.properties)).equal(false);
});

it('Setting set, nonce expired', () => {
strategy['maxNonceTimeDelay'] = HOUR / 1000;

expect(
strategy['hasNonceExpired'](
query.change({
'openid.response_nonce': `${getISODate(
new Date(Date.now() - 2 * HOUR),
)}8df86bac92ad1addaf3735a5aabdc6e2a7`,
}),
),
).equal(true);
});

it('Setting set, nonce not expired', () => {
strategy['maxNonceTimeDelay'] = HOUR / 1000;

expect(
strategy['hasNonceExpired'](
query.change({
'openid.response_nonce': `${getISODate(
new Date(Date.now() + HOUR / 2),
)}8df86bac92ad1addaf3735a5aabdc6e2a7`,
}),
),
).equal(false);
});
});
});

0 comments on commit c77de86

Please sign in to comment.