Skip to content

Commit

Permalink
Add optional InstallProvider#stateVerification flag (#1329)
Browse files Browse the repository at this point in the history
* Adds optional InstallProvider#stateVerification flag
  • Loading branch information
srajiang committed Dec 10, 2021
1 parent 0e50824 commit ba5e825
Show file tree
Hide file tree
Showing 3 changed files with 75 additions and 25 deletions.
5 changes: 5 additions & 0 deletions packages/oauth/src/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ export enum ErrorCode {
AuthorizationError = 'slack_oauth_installer_authorization_error',
GenerateInstallUrlError = 'slack_oauth_generate_url_error',
MissingStateError = 'slack_oauth_missing_state',
MissingCodeError = 'slack_oauth_missing_code',
UnknownError = 'slack_oauth_unknown_error',
}

Expand All @@ -25,6 +26,10 @@ export class MissingStateError extends Error implements CodedError {
public code = ErrorCode.MissingStateError;
}

export class MissingCodeError extends Error implements CodedError {
public code = ErrorCode.MissingCodeError;
}

export class UnknownError extends Error implements CodedError {
public code = ErrorCode.UnknownError;
}
Expand Down
37 changes: 26 additions & 11 deletions packages/oauth/src/index.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -396,9 +396,8 @@ describe('OAuth', async () => {
verifyStateParam: sinon.fake.resolves({})
}
});

it('should call the failure callback due to missing state query parameter on the URL', async () => {
const req = { url: 'http://example.com' };
it('should call the failure callback due to missing code query parameter on the URL', async () => {
const req = { headers: { host: 'example.com'}, url: 'http://example.com' };
let sent = false;
const res = { send: () => { sent = true; } };
const callbackOptions = {
Expand All @@ -407,7 +406,7 @@ describe('OAuth', async () => {
assert.fail('should have failed');
},
failure: async (error, installOptions, req, res) => {
assert.equal(error.code, ErrorCode.MissingStateError)
assert.equal(error.code, ErrorCode.MissingCodeError)
res.send('failure');
},
}
Expand All @@ -416,9 +415,8 @@ describe('OAuth', async () => {

assert.isTrue(sent);
});

it('should call the failure callback due to missing code query parameter on the URL', async () => {
const req = { url: 'http://example.com' };
it('should call the failure callback due to missing state query parameter on the URL', async () => {
const req = { headers: { host: 'example.com'}, url: 'http://example.com?code=1234' };
let sent = false;
const res = { send: () => { sent = true; } };
const callbackOptions = {
Expand All @@ -437,8 +435,26 @@ describe('OAuth', async () => {
assert.isTrue(sent);
});

it('should call the success callback when state query param is missing but stateVerification disabled', async () => {
const req = { headers: { host: 'example.com'}, url: 'http://example.com?code=1234' };
let sent = false;
const res = { send: () => { sent = true; } };
const callbackOptions = {
success: async (installation, installOptions, req, res) => {
res.send('successful!');
},
failure: async (error, installOptions, req, res) => {
assert.fail('should have succeeded');
},
}
const installer = new InstallProvider({ clientId, clientSecret, stateSecret, stateVerification: false, installationStore, logger: noopLogger });
await installer.handleCallback(req, res, callbackOptions);

assert.isTrue(sent);
});

it('should call the failure callback if an access_denied error query parameter was returned on the URL', async () => {
const req = { url: 'http://example.com?error=access_denied' };
const req = { headers: { host: 'example.com'}, url: 'http://example.com?error=access_denied' };
let sent = false;
const res = { send: () => { sent = true; } };
const callbackOptions = {
Expand Down Expand Up @@ -466,14 +482,13 @@ describe('OAuth', async () => {
},
failure: async (error, installOptions, req, res) => {
assert.fail(error.message);
res.send('failure');
},
}

const installer = new InstallProvider({ clientId, clientSecret, installationStore, stateStore: fakeStateStore });
const fakeState = 'fakeState';
const fakeCode = 'fakeCode';
const req = { url: `http://example.com?state=${fakeState}&code=${fakeCode}` };
const req = { headers: { host: 'example.com'}, url: `http://example.com?state=${fakeState}&code=${fakeCode}` };
await installer.handleCallback(req, res, callbackOptions);
assert.isTrue(sent);
assert.equal(fakeStateStore.verifyStateParam.callCount, 1);
Expand All @@ -494,7 +509,7 @@ describe('OAuth', async () => {
const installer = new InstallProvider({ clientId, clientSecret, stateSecret, installationStore, stateStore: fakeStateStore, authVersion: 'v1' });
const fakeState = 'fakeState';
const fakeCode = 'fakeCode';
const req = { url: `http://example.com?state=${fakeState}&code=${fakeCode}` };
const req = { headers: { host: 'example.com'}, url: `http://example.com?state=${fakeState}&code=${fakeCode}` };
await installer.handleCallback(req, res, callbackOptions);
assert.isTrue(sent);
assert.equal(fakeStateStore.verifyStateParam.callCount, 1);
Expand Down
58 changes: 44 additions & 14 deletions packages/oauth/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
InstallerInitializationError,
UnknownError,
MissingStateError,
MissingCodeError,
GenerateInstallUrlError,
AuthorizationError,
} from './errors';
Expand Down Expand Up @@ -41,6 +42,7 @@ class ClearStateStore implements StateStore {
* @param clientSecret - Your apps client Secret
* @param stateSecret - Used to sign and verify the generated state when using the built-in `stateStore`
* @param stateStore - Replacement function for the built-in `stateStore`
* @param stateVerification - Pass in false to disable state parameter verification
* @param installationStore - Interface to store and retrieve installation data from the database
* @param authVersion - Can be either `v1` or `v2`. Determines which slack Oauth URL and method to use
* @param logger - Pass in your own Logger if you don't want to use the built-in one
Expand All @@ -63,11 +65,14 @@ export class InstallProvider {

private authorizationUrl: string;

private stateVerification: boolean;

public constructor({
clientId,
clientSecret,
stateSecret = undefined,
stateStore = undefined,
stateVerification = true,
installationStore = new MemoryInstallationStore(),
authVersion = 'v2',
logger = undefined,
Expand All @@ -88,7 +93,10 @@ export class InstallProvider {
} else {
this.logger = getLogger('OAuth:InstallProvider', logLevel ?? LogLevel.INFO, logger);
}

this.stateVerification = stateVerification;
if (!stateVerification) {
this.logger.warn("You've set InstallProvider#stateVerification to false. This flag is intended to enable org-wide app installations from admin pages. If this isn't your scenario, we recommend setting stateVerification to true and starting your OAuth flow from the provided `/slack/install` or your own starting endpoint.");
}
// Setup stateStore
if (stateStore !== undefined) {
this.stateStore = stateStore;
Expand Down Expand Up @@ -247,6 +255,15 @@ export class InstallProvider {
return Promise.all(refreshPromises);
}

/**
* Returns search params from a URL and ignores protocol / hostname as those
* aren't guaranteed to be accurate e.g. in x-forwarded- scenarios
*/
private static extractSearchParams(req: IncomingMessage): URLSearchParams {
const { searchParams } = new URL(req.url as string, `https://${req.headers.host}`);
return searchParams;
}

/**
* Returns a URL that is suitable for including in an Add to Slack button
* Uses stateStore to generate a value for the state query param.
Expand Down Expand Up @@ -294,7 +311,6 @@ export class InstallProvider {
}
params.append('user_scope', userScopes);
}

slackURL.search = params.toString();
return slackURL.toString();
}
Expand All @@ -312,30 +328,43 @@ export class InstallProvider {
req: IncomingMessage,
res: ServerResponse,
options?: CallbackOptions,
installOptions?: InstallURLOptions,
): Promise<void> {
let parsedUrl;
let code: string;
let flowError: string;
let state: string;
let installOptions: InstallURLOptions;

try {
if (req.url !== undefined) {
parsedUrl = new URL(req.url);
flowError = parsedUrl.searchParams.get('error') as string;
// Note: Protocol/ host of object are not necessarily accurate
// and shouldn't be relied on
// intended only for accessing searchParams only
const searchParams = InstallProvider.extractSearchParams(req);
flowError = searchParams.get('error') as string;
if (flowError === 'access_denied') {
throw new AuthorizationError('User cancelled the OAuth installation flow!');
}
code = parsedUrl.searchParams.get('code') as string;
state = parsedUrl.searchParams.get('state') as string;
if (!state || !code) {
throw new MissingStateError('redirect url is missing state or code query parameters');
code = searchParams.get('code') as string;
state = searchParams.get('state') as string;
if (!code) {
throw new MissingCodeError('Redirect url is missing the required code query parameter');
}
if (this.stateVerification && !state) {
throw new MissingStateError('Redirect url is missing the state query parameter. If this is intentional, see options for disabling default state verification.');
}
} else {
throw new UnknownError('Something went wrong');
}
// If state verification is enabled OR state exists, attempt to verify, otherwise ignore
if (this.stateVerification || state) {
// eslint-disable-next-line no-param-reassign
installOptions = await this.stateStore.verifyStateParam(new Date(), state);
}
if (!installOptions) {
const emptyInstallOptions: InstallURLOptions = { scopes: [] };
// eslint-disable-next-line no-param-reassign
installOptions = emptyInstallOptions;
}

installOptions = await this.stateStore.verifyStateParam(new Date(), state);
const client = new WebClient(undefined, this.clientOptions);

// Start: Build the installation object
Expand Down Expand Up @@ -461,8 +490,7 @@ export class InstallProvider {
configurationUrl: resp.incoming_webhook.configuration_url,
};
}

if (installOptions !== undefined && installOptions.metadata !== undefined) {
if (installOptions && installOptions.metadata !== undefined) {
// Pass the metadata in state parameter if exists.
// Developers can use the value for additional/custom data associated with the installation.
installation.metadata = installOptions.metadata;
Expand Down Expand Up @@ -504,6 +532,7 @@ export interface InstallProviderOptions {
clientSecret: string;
stateStore?: StateStore; // default ClearStateStore
stateSecret?: string; // ClearStateStoreOptions['secret']; // required when using default stateStore
stateVerification?: boolean; // default true
installationStore?: InstallationStore; // default MemoryInstallationStore
authVersion?: 'v1' | 'v2'; // default 'v2'
logger?: Logger;
Expand Down Expand Up @@ -546,6 +575,7 @@ export interface CallbackOptions {
export interface StateStore {
// Returned Promise resolves for a string which can be used as an
// OAuth state param.
// TODO: Revisit design. Does installOptions need to be encoded in state if metadata is static?
generateStateParam: (installOptions: InstallURLOptions, now: Date) => Promise<string>;

// Returned Promise resolves for InstallURLOptions that were stored in the state
Expand Down

0 comments on commit ba5e825

Please sign in to comment.