forked from slackapi/node-slack-sdk
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Fix slackapi#1439 Add built-in StateStore implementations using serve…
…r-side database
- Loading branch information
Showing
11 changed files
with
262 additions
and
21 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file was deleted.
Oops, something went wrong.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,8 @@ | ||
import ClearStateStore from './clear-state-store'; | ||
import { StateStoreChaiTestRunner } from './spec-utils'; | ||
|
||
const testRunner = new StateStoreChaiTestRunner({ | ||
stateStore: new ClearStateStore('secret'), | ||
shouldVerifyOnlyOnce: false, | ||
}); | ||
testRunner.enableTests('ClearStateStore'); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,10 @@ | ||
import os from 'os'; | ||
import { FileStateStore } from './file-state-store'; | ||
import { StateStoreChaiTestRunner } from './spec-utils'; | ||
|
||
const testRunner = new StateStoreChaiTestRunner({ | ||
stateStore: new FileStateStore({ | ||
baseDir: os.tmpdir(), | ||
}), | ||
}); | ||
testRunner.enableTests('FileStateStore'); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,109 @@ | ||
import { homedir } from 'os'; | ||
import fs from 'fs'; | ||
import path from 'path'; | ||
import { randomUUID } from 'crypto'; | ||
import { StateStore, StateObj } from './interface'; | ||
import { InstallURLOptions } from '../install-url-options'; | ||
import { InvalidStateError } from '../errors'; | ||
|
||
export interface FileStateStoreArgs { | ||
stateExpirationSeconds?: number; | ||
baseDir?: string; | ||
} | ||
|
||
export class FileStateStore implements StateStore { | ||
private baseDir: string; | ||
|
||
private stateExpirationSeconds: number; | ||
|
||
public constructor(args: FileStateStoreArgs) { | ||
this.baseDir = args.baseDir !== undefined ? | ||
args.baseDir : | ||
`${homedir()}/.bolt-js-oauth-states`; | ||
this.stateExpirationSeconds = args.stateExpirationSeconds !== undefined ? | ||
args.stateExpirationSeconds : | ||
600; | ||
} | ||
|
||
public async generateStateParam( | ||
installOptions: InstallURLOptions, | ||
now: Date, | ||
): Promise<string> { | ||
const state = randomUUID(); | ||
const source: StateObj = { | ||
installOptions, | ||
now, | ||
random: Math.floor(Math.random() * 1000000), | ||
}; | ||
this.writeToFile(state, source); | ||
return state; | ||
} | ||
|
||
public async verifyStateParam( | ||
now: Date, | ||
state: string, | ||
): Promise<InstallURLOptions> { | ||
try { | ||
if (this.findFile(state)) { | ||
// decode the state using the secret | ||
let decoded: StateObj | undefined; | ||
try { | ||
decoded = this.readFile(state); | ||
} catch (e) { | ||
const message = `Failed to load the data represented by the state parameter (error: ${e})`; | ||
throw new InvalidStateError(message); | ||
} | ||
if (decoded !== undefined) { | ||
// Check if the state value is not too old | ||
const generatedAt = new Date(decoded.now); | ||
const passedSeconds = Math.floor( | ||
(now.getTime() - generatedAt.getTime()) / 1000, | ||
); | ||
if (passedSeconds > this.stateExpirationSeconds) { | ||
throw new InvalidStateError('The state value is already expired'); | ||
} | ||
// return installOptions | ||
return decoded.installOptions; | ||
} | ||
} | ||
} finally { | ||
this.deleteFile(state); | ||
} | ||
throw new InvalidStateError('The state value is already expired'); | ||
} | ||
|
||
// ------------------------------------------- | ||
// private methods | ||
// ------------------------------------------- | ||
|
||
private writeToFile(filename: string, data: StateObj): void { | ||
fs.mkdirSync(this.baseDir, { recursive: true }); | ||
const fullpath = path.resolve(`${this.baseDir}/${filename}`); | ||
fs.writeFileSync(fullpath, JSON.stringify(data)); | ||
} | ||
|
||
private findFile(filename: string): boolean { | ||
const fullpath = path.resolve(`${this.baseDir}/${filename}`); | ||
return fs.existsSync(fullpath); | ||
} | ||
|
||
private readFile(filename: string): StateObj | undefined { | ||
const fullpath = path.resolve(`${this.baseDir}/${filename}`); | ||
try { | ||
const data = fs.readFileSync(fullpath); | ||
if (data !== undefined) { | ||
return JSON.parse(data.toString()); | ||
} | ||
return undefined; | ||
} catch (_) { | ||
return undefined; | ||
} | ||
} | ||
|
||
private deleteFile(filename: string): void { | ||
const fullpath = path.resolve(`${this.baseDir}/${filename}`); | ||
if (fs.existsSync(fullpath)) { | ||
fs.unlinkSync(fullpath); | ||
} | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,2 +1,3 @@ | ||
export { StateStore, StateObj } from './interface'; | ||
export { default as ClearStateStore } from './clear-state-store'; | ||
export { FileStateStore, FileStateStoreArgs } from './file-state-store'; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,91 @@ | ||
/* eslint-disable @typescript-eslint/no-explicit-any */ | ||
/* eslint-disable import/no-extraneous-dependencies */ | ||
import { assert } from 'chai'; | ||
import { StateStore } from './interface'; | ||
import { InstallURLOptions } from '../install-url-options'; | ||
|
||
export interface StateStoreChaiTestRunnerArgs { | ||
stateStore: StateStore; | ||
shouldVerifyOnlyOnce?: boolean; | ||
} | ||
|
||
export class StateStoreChaiTestRunner { | ||
private stateStore: StateStore; | ||
|
||
private shouldVerifyOnlyOnce: boolean; | ||
|
||
public constructor(args: StateStoreChaiTestRunnerArgs) { | ||
this.stateStore = args.stateStore; | ||
this.shouldVerifyOnlyOnce = args.shouldVerifyOnlyOnce === undefined ? | ||
true : | ||
args.shouldVerifyOnlyOnce; | ||
} | ||
|
||
public async enableTests(testTarget: string): Promise<void> { | ||
describe(testTarget, () => { | ||
it('should generate and verify valid state values', async () => { | ||
const { stateStore } = this; | ||
const options: InstallURLOptions = { | ||
scopes: ['commands', 'chat:write'], | ||
teamId: 'T111', | ||
redirectUri: 'https://www.example.com/slack/oauth_redirect', | ||
userScopes: ['search:read'], | ||
metadata: 'the metadata', | ||
}; | ||
const state = await stateStore.generateStateParam(options, new Date()); | ||
assert.isNotEmpty(state); | ||
const result = await stateStore.verifyStateParam(new Date(), state); | ||
assert.deepEqual(result, options); | ||
}); | ||
|
||
it('should detect old state values', async () => { | ||
const { stateStore } = this; | ||
const installUrlOptions = { scopes: ['channels:read'] }; | ||
const fifteenMinutesLater = new Date( | ||
new Date().getTime() + 15 * 60 * 1000, | ||
); | ||
const state = await stateStore.generateStateParam( | ||
installUrlOptions, | ||
new Date(), | ||
); | ||
try { | ||
await stateStore.verifyStateParam(fifteenMinutesLater, state); | ||
assert.fail('Exception should be thrown'); | ||
} catch (e: any) { | ||
assert.equal(e.code, 'slack_oauth_invalid_state'); | ||
} | ||
}); | ||
|
||
if (this.shouldVerifyOnlyOnce) { | ||
it('should detect multiple consumption', async () => { | ||
const { stateStore } = this; | ||
const installUrlOptions = { scopes: ['channels:read'] }; | ||
Array.from(Array(200)).forEach(async () => { | ||
// generate other states | ||
await stateStore.generateStateParam(installUrlOptions, new Date()); | ||
}); | ||
const state = await stateStore.generateStateParam( | ||
installUrlOptions, | ||
new Date(), | ||
); | ||
const result = await stateStore.verifyStateParam(new Date(), state); | ||
assert.exists(result); | ||
let expectedlyReturnedResult; | ||
try { | ||
expectedlyReturnedResult = await stateStore.verifyStateParam( | ||
new Date(), | ||
state, | ||
); | ||
assert.fail('Exception should be thrown'); | ||
} catch (e: any) { | ||
assert.equal( | ||
e.code, | ||
'slack_oauth_invalid_state', | ||
`${state} ${JSON.stringify(expectedlyReturnedResult)}`, | ||
); | ||
} | ||
}); | ||
} | ||
}); | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters