Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

CAS-6351/authenticate handle verdict #101

Merged
merged 25 commits into from
Feb 8, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 10 additions & 16 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ This is the asynchronous version of the castle integration. This is for events w
```js
import { EVENTS } from '@castleio/sdk';

track({
castle.track({
event: EVENTS.EMAIL_CHANGE_SUCCEEDED,
user_id: user.id,
user_traits: {
Expand All @@ -72,7 +72,7 @@ track({
context: {
ip: request.ip,
client_id: request.cookies['__cid'],
headers: request.cookies,
headers: request.headers,
},
});
```
Expand Down Expand Up @@ -119,18 +119,12 @@ Response format

### All config options for `track` and `authenticate`

| Config option | Explanation |
| ------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- |
| event | `string` - The event generated by the user. It can be either an event from `EVENTS` or a custom one. |
| user_id | `string` - The `user_id` of the end user. |
| user_traits | `object` - An optional, recommended, object containing user information, such as `email` and `registered_at`. |
| properties | `object` - An optional object containing custom information. |
| Config option | Explanation |
| ------------- | ----------- |
| event | `string` - The event generated by the user. It can be either an event from `EVENTS` or a custom one. |
| user_id | `string` - The `user_id` of the end user. |
| user_traits | `object` - An optional, recommended, object containing user information, such as `email` and `registered_at`. |
| properties | `object` - An optional object containing custom information. |
| created_at | `string` - An optional ISO date string indicating when the event occurred, in cases where this might be different from the time when the request is made. |
| device_token | `string` - The optional device token, used for mitigating or escalating. |
| context | `object` - The request context information. See information below. |

| Context option | Explanation |
| -------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| ip | `string` - The IP address of the request. Note that this needs to be the original request IP, not the IP of an internal proxy, such as nginx. |
| client_id | `string` - The client ID, generated by the `c.js` integration on the front end. Commonly found in the `__cid` cookie in `request.cookies` or `request.cookie`, or in some cases the `X-CASTLE-CLIENT-ID` header. |
| headers | `object` - The headers object on the request, commonly `request.headers`. |
| device_token | `string` - The optional device token, used for mitigating or escalating. |
| context | `object` - The request context information. |
144 changes: 7 additions & 137 deletions src/Castle.ts
Original file line number Diff line number Diff line change
@@ -1,39 +1,10 @@
import fetch from 'node-fetch';
import AbortController from 'abort-controller';
import pino from 'pino';

import { AuthenticateResult, Payload } from './models';
import {
CommandAuthenticateService,
CommandTrackService,
} from './command/command.module';
import {
FailoverResponsePrepareService,
FailoverStrategy,
} from './failover/failover.module';
import { LoggerService } from './logger/logger.module';
import { APIAuthenticateService, APITrackService } from './api/api.module';
import { FailoverResponsePrepareService } from './failover/failover.module';
import { Configuration } from './configuraton';

// The body on the request is a stream and can only be
// read once, by default. This is a workaround so that the
// logging functions can read the body independently
// of the handlers.
const getBody = async (response: any) => {
if (response.cachedBody) {
return response.cachedBody;
}

try {
response.cachedBody = await response.json();
} catch (e) {
response.cachedBody = {};
}

return response.cachedBody;
};

const isTimeout = (e: Error) => e.name === 'AbortError';

export class Castle {
private logger: pino.Logger;
private configuration: Configuration;
Expand All @@ -54,56 +25,10 @@ export class Castle {
}

if (this.configuration.doNotTrack) {
return FailoverResponsePrepareService.call(
params.user_id,
'do not track',
this.configuration.failoverStrategy
);
}

let response: Response;
const controller = new AbortController();
const timeout = setTimeout(() => {
controller.abort();
}, this.configuration.timeout);
const { requestUrl, requestOptions } = CommandAuthenticateService.call(
controller,
params,
this.configuration
);

try {
response = await this.getFetch()(requestUrl, requestOptions);
} catch (err) {
LoggerService.call({ requestUrl, requestOptions, err }, this.logger);

if (isTimeout(err)) {
return this.handleFailover(params.user_id, 'timeout', err);
} else {
throw err;
}
} finally {
clearTimeout(timeout);
}

// Wait to get body here to prevent race conditions
// on `.json()` because we attempt to read it in
// multiple places.
const body = await getBody(response);

LoggerService.call(
{ requestUrl, requestOptions, response, body },
this.logger
);

if (response.status >= 500) {
return this.handleFailover(params.user_id, 'server error');
return this.generateDoNotTrackResponse(params.user_id);
}

this.handleUnauthorized(response);
this.handleBadResponse(response);

return body;
return APIAuthenticateService.call(params, this.configuration, this.logger);
}

public async track(params: Payload): Promise<void> {
Expand All @@ -115,69 +40,14 @@ export class Castle {
return;
}

let response: Response;
const controller = new AbortController();
const timeout = setTimeout(() => {
controller.abort();
}, this.configuration.timeout);
const { requestUrl, requestOptions } = CommandTrackService.call(
controller,
params,
this.configuration
);

try {
response = await this.getFetch()(requestUrl, requestOptions);
} catch (err) {
if (isTimeout(err)) {
return LoggerService.call(
{ requestUrl, requestOptions, err },
this.logger
);
}
} finally {
clearTimeout(timeout);
}

LoggerService.call({ requestUrl, requestOptions, response }, this.logger);
this.handleUnauthorized(response);
this.handleBadResponse(response);
}

private getFetch() {
return this.configuration.overrideFetch || fetch;
return APITrackService.call(params, this.configuration, this.logger);
}

private handleFailover(
userId: string,
reason: string,
err?: Error
): AuthenticateResult {
// Have to check it this way to make sure TS understands
// that this.failoverStrategy is of type Verdict,
// not FailoverStrategyType.
if (this.configuration.failoverStrategy === FailoverStrategy.throw) {
throw err;
}

private generateDoNotTrackResponse(userId) {
return FailoverResponsePrepareService.call(
userId,
reason,
'do not track',
this.configuration.failoverStrategy
);
}

private handleUnauthorized(response: Response) {
if (response.status === 401) {
throw new Error(
'Castle: Failed to authenticate with API, please verify the secret.'
);
}
}

private handleBadResponse(response: Response) {
if (response.status >= 400 && response.status < 500) {
throw new Error(`Castle: API response not ok, got ${response.status}.`);
}
}
}
87 changes: 85 additions & 2 deletions src/api/services/api-authenticate.service.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,88 @@
import { Configuration } from '../../configuraton';
import { InternalServerError } from '../../errors';
import { AuthenticateResult, Payload } from '../../models';
import { CommandAuthenticateService } from '../../command/command.module';
import { CoreProcessResponseService } from '../../core/core.module';
import {
FailoverResponsePrepareService,
FailoverStrategy,
} from '../../failover/failover.module';
import { LoggerService } from '../../logger/logger.module';
import AbortController from 'abort-controller';
import fetch from 'node-fetch';
import pino from 'pino';

const isTimeout = (e: Error) => e.name === 'AbortError';

const handleFailover = (
userId: string,
reason: string,
configuration: Configuration,
err?: Error
): AuthenticateResult => {
// Have to check it this way to make sure TS understands
// that this.failoverStrategy is of type Verdict,
// not FailoverStrategyType.
if (configuration.failoverStrategy === FailoverStrategy.throw) {
throw err;
}

return FailoverResponsePrepareService.call(
userId,
reason,
configuration.failoverStrategy
);
};

export const APIAuthenticateService = {
call: () => {
return;
call: async (
params: Payload,
configuration: Configuration,
logger: pino.Logger
): Promise<AuthenticateResult> => {
const fetcher = configuration.overrideFetch || fetch;

let response: Response;
const controller = new AbortController();
const timeout = setTimeout(() => {
controller.abort();
}, configuration.timeout);
const { requestUrl, requestOptions } = CommandAuthenticateService.call(
controller,
params,
configuration
);

try {
response = await fetcher(requestUrl, requestOptions);
} catch (err) {
LoggerService.call({ requestUrl, requestOptions, err }, logger);

if (isTimeout(err)) {
return handleFailover(params.user_id, 'timeout', configuration, err);
} else {
throw err;
}
} finally {
clearTimeout(timeout);
}

let processedResponse;
try {
processedResponse = await CoreProcessResponseService.call(
requestUrl,
requestOptions,
response,
logger
);
} catch (e) {
if (e instanceof InternalServerError) {
return handleFailover(params.user_id, 'server error', configuration);
} else {
throw e;
}
}

return processedResponse;
},
};
52 changes: 52 additions & 0 deletions src/api/services/api-track.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { Configuration } from '../../configuraton';
import { AuthenticateResult, Payload } from '../../models';
import { CommandTrackService } from '../../command/command.module';
import { CoreProcessResponseService } from '../../core/core.module';
import {
FailoverResponsePrepareService,
FailoverStrategy,
} from '../../failover/failover.module';
import { LoggerService } from '../../logger/logger.module';
import AbortController from 'abort-controller';
import fetch from 'node-fetch';
import pino from 'pino';

const isTimeout = (e: Error) => e.name === 'AbortError';

export const APITrackService = {
call: async (
params: Payload,
configuration: Configuration,
logger: pino.Logger
): Promise<void> => {
const fetcher = configuration.overrideFetch || fetch;

let response: Response;
const controller = new AbortController();
const timeout = setTimeout(() => {
controller.abort();
}, configuration.timeout);
const { requestUrl, requestOptions } = CommandTrackService.call(
controller,
params,
configuration
);

try {
response = await fetcher(requestUrl, requestOptions);
} catch (err) {
if (isTimeout(err)) {
return LoggerService.call({ requestUrl, requestOptions, err }, logger);
}
} finally {
clearTimeout(timeout);
}

CoreProcessResponseService.call(
requestUrl,
requestOptions,
response,
logger
);
},
};
1 change: 1 addition & 0 deletions src/api/services/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
export * from './api-authenticate.service';
export * from './api-track.service';
2 changes: 1 addition & 1 deletion src/client-id/services/client-id-extract.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { IncomingHttpHeaders } from 'http';
import { HeadersGetCookieService } from '../../headers/headers.module';

export const ClientIdExtractService = {
call: (headers: IncomingHttpHeaders, cookies = '') => {
call: (headers: IncomingHttpHeaders = {}, cookies = '') => {
return (
headers['x-castle-client-id'] ||
HeadersGetCookieService.call(cookies, '__cid') ||
Expand Down
Loading