Skip to content

Commit

Permalink
Actions GA (#1172)
Browse files Browse the repository at this point in the history
## Description
Merge beta into main 

## Documentation

Does this require changes to the WorkOS Docs? E.g. the [API
Reference](https://workos.com/docs/reference) or code snippets need
updates.

```
[ ] Yes
```

If yes, link a related docs PR and add a docs maintainer as a reviewer.
Their approval is required.

---------

Co-authored-by: Paul Asjes <[email protected]>
Co-authored-by: Matt Dzwonczyk <[email protected]>
Co-authored-by: Paul Asjes <[email protected]>
Co-authored-by: pantera <[email protected]>
Co-authored-by: Jason Roelofs <[email protected]>
Co-authored-by: Michael Hadley <[email protected]>
Co-authored-by: Paul Asjes <[email protected]>
Co-authored-by: Nazar Kuzmenko <[email protected]>
Co-authored-by: Cameron Matheson <[email protected]>
Co-authored-by: Giovanni Carvelli <[email protected]>
Co-authored-by: Laura Beatris <[email protected]>
Co-authored-by: Blair Lunceford <[email protected]>
Co-authored-by: Stanley Phu <[email protected]>
Co-authored-by: Stanley Phu <[email protected]>
Co-authored-by: Christopher M <[email protected]>
Co-authored-by: alisherry <[email protected]>
Co-authored-by: Jônatas Santos <[email protected]>
Co-authored-by: Sheldon Vaughn <[email protected]>
Co-authored-by: Kendall Strautman Swarthout <[email protected]>
Co-authored-by: Amy Hanlon <[email protected]>
Co-authored-by: Dan Dorman <[email protected]>
Co-authored-by: Dan Dorman <[email protected]>
  • Loading branch information
23 people authored Nov 19, 2024
1 parent b5b7805 commit 6086f7f
Show file tree
Hide file tree
Showing 19 changed files with 735 additions and 145 deletions.
100 changes: 100 additions & 0 deletions src/actions/actions.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import crypto from 'crypto';
import { WorkOS } from '../workos';
import mockActionContext from './fixtures/action-context.json';
const workos = new WorkOS('sk_test_Sz3IQjepeSWaI4cMS4ms4sMuU');
import { NodeCryptoProvider } from '../common/crypto';

describe('Actions', () => {
let secret: string;

beforeEach(() => {
secret = 'secret';
});

describe('signResponse', () => {
describe('type: authentication', () => {
it('returns a signed response', async () => {
const nodeCryptoProvider = new NodeCryptoProvider();

const response = await workos.actions.signResponse(
{
type: 'authentication',
verdict: 'Allow',
},
secret,
);

const signedPayload = `${response.payload.timestamp}.${JSON.stringify(
response.payload,
)}`;

const expectedSig = await nodeCryptoProvider.computeHMACSignatureAsync(
signedPayload,
secret,
);

expect(response.object).toEqual('authentication_action_response');
expect(response.payload.verdict).toEqual('Allow');
expect(response.payload.timestamp).toBeGreaterThan(0);
expect(response.signature).toEqual(expectedSig);
});
});

describe('type: user_registration', () => {
it('returns a signed response', async () => {
const nodeCryptoProvider = new NodeCryptoProvider();

const response = await workos.actions.signResponse(
{
type: 'user_registration',
verdict: 'Deny',
errorMessage: 'User already exists',
},
secret,
);

const signedPayload = `${response.payload.timestamp}.${JSON.stringify(
response.payload,
)}`;

const expectedSig = await nodeCryptoProvider.computeHMACSignatureAsync(
signedPayload,
secret,
);

expect(response.object).toEqual('user_registration_action_response');
expect(response.payload.verdict).toEqual('Deny');
expect(response.payload.timestamp).toBeGreaterThan(0);
expect(response.signature).toEqual(expectedSig);
});
});
});

describe('verifyHeader', () => {
it('aliases to the signature provider', async () => {
const spy = jest.spyOn(
// tslint:disable-next-line
workos.actions['signatureProvider'],
'verifyHeader',
);

const timestamp = Date.now() * 1000;
const unhashedString = `${timestamp}.${JSON.stringify(
mockActionContext,
)}`;
const signatureHash = crypto
.createHmac('sha256', secret)
.update(unhashedString)
.digest()
.toString('hex');

await workos.actions.verifyHeader({
payload: mockActionContext,
sigHeader: `t=${timestamp}, v1=${signatureHash}`,
secret,
});

expect(spy).toHaveBeenCalled();
});
});
});
70 changes: 70 additions & 0 deletions src/actions/actions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import { SignatureProvider } from '../common/crypto';
import { CryptoProvider } from '../common/crypto/crypto-provider';
import { unreachable } from '../common/utils/unreachable';
import {
AuthenticationActionResponseData,
ResponsePayload,
UserRegistrationActionResponseData,
} from './interfaces/response-payload';

export class Actions {
private signatureProvider: SignatureProvider;

constructor(cryptoProvider: CryptoProvider) {
this.signatureProvider = new SignatureProvider(cryptoProvider);
}

private get computeSignature() {
return this.signatureProvider.computeSignature.bind(this.signatureProvider);
}

get verifyHeader() {
return this.signatureProvider.verifyHeader.bind(this.signatureProvider);
}

serializeType(
type:
| AuthenticationActionResponseData['type']
| UserRegistrationActionResponseData['type'],
) {
switch (type) {
case 'authentication':
return 'authentication_action_response';
case 'user_registration':
return 'user_registration_action_response';
default:
return unreachable(type);
}
}

async signResponse(
data: AuthenticationActionResponseData | UserRegistrationActionResponseData,
secret: string,
) {
let errorMessage: string | undefined;
const { verdict, type } = data;

if (verdict === 'Deny' && data.errorMessage) {
errorMessage = data.errorMessage;
}

const responsePayload: ResponsePayload = {
timestamp: Date.now(),
verdict,
...(verdict === 'Deny' &&
data.errorMessage && { error_message: errorMessage }),
};

const response = {
object: this.serializeType(type),
payload: responsePayload,
signature: await this.computeSignature(
responsePayload.timestamp,
responsePayload,
secret,
),
};

return response;
}
}
39 changes: 39 additions & 0 deletions src/actions/fixtures/action-context.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
{
"user": {
"object": "user",
"id": "01JATCHZVEC5EPANDPEZVM68Y9",
"email": "[email protected]",
"first_name": "Jane",
"last_name": "Doe",
"email_verified": true,
"profile_picture_url": "https://example.com/jane.jpg",
"created_at": "2024-10-22T17:12:50.746Z",
"updated_at": "2024-10-22T17:12:50.746Z"
},
"ip_address": "50.141.123.10",
"user_agent": "Mozilla/5.0",
"issuer": "test",
"object": "authentication_action_context",
"organization": {
"object": "organization",
"id": "01JATCMZJY26PQ59XT9BNT0FNN",
"name": "Foo Corp",
"allow_profiles_outside_organization": false,
"domains": [],
"lookup_key": "my-key",
"created_at": "2024-10-22T17:12:50.746Z",
"updated_at": "2024-10-22T17:12:50.746Z"
},
"organization_membership": {
"object": "organization_membership",
"id": "01JATCNVYCHT1SZGENR4QTXKRK",
"user_id": "01JATCHZVEC5EPANDPEZVM68Y9",
"organization_id": "01JATCMZJY26PQ59XT9BNT0FNN",
"role": {
"slug": "member"
},
"status": "active",
"created_at": "2024-10-22T17:12:50.746Z",
"updated_at": "2024-10-22T17:12:50.746Z"
}
}
22 changes: 22 additions & 0 deletions src/actions/interfaces/response-payload.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
export interface ResponsePayload {
timestamp: number;
verdict?: 'Allow' | 'Deny';
errorMessage?: string;
}

interface AllowResponseData {
verdict: 'Allow';
}

interface DenyResponseData {
verdict: 'Deny';
errorMessage?: string;
}

export type AuthenticationActionResponseData =
| (AllowResponseData & { type: 'authentication' })
| (DenyResponseData & { type: 'authentication' });

export type UserRegistrationActionResponseData =
| (AllowResponseData & { type: 'user_registration' })
| (DenyResponseData & { type: 'user_registration' });
68 changes: 68 additions & 0 deletions src/common/crypto/CryptoProvider.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import crypto from 'crypto';
import { NodeCryptoProvider } from './NodeCryptoProvider';
import { SubtleCryptoProvider } from './SubtleCryptoProvider';
import mockWebhook from '../../webhooks/fixtures/webhook.json';
import { SignatureProvider } from './SignatureProvider';

describe('CryptoProvider', () => {
let payload: any;
let secret: string;
let timestamp: number;
let signatureHash: string;

beforeEach(() => {
payload = mockWebhook;
secret = 'secret';
timestamp = Date.now() * 1000;
const unhashedString = `${timestamp}.${JSON.stringify(payload)}`;
signatureHash = crypto
.createHmac('sha256', secret)
.update(unhashedString)
.digest()
.toString('hex');
});

describe('when computing HMAC signature', () => {
it('returns the same for the Node crypto and Web Crypto versions', async () => {
const nodeCryptoProvider = new NodeCryptoProvider();
const subtleCryptoProvider = new SubtleCryptoProvider();

const stringifiedPayload = JSON.stringify(payload);
const payloadHMAC = `${timestamp}.${stringifiedPayload}`;

const nodeCompare = await nodeCryptoProvider.computeHMACSignatureAsync(
payloadHMAC,
secret,
);
const subtleCompare =
await subtleCryptoProvider.computeHMACSignatureAsync(
payloadHMAC,
secret,
);

expect(nodeCompare).toEqual(subtleCompare);
});
});

describe('when securely comparing', () => {
it('returns the same for the Node crypto and Web Crypto versions', async () => {
const nodeCryptoProvider = new NodeCryptoProvider();
const subtleCryptoProvider = new SubtleCryptoProvider();
const signatureProvider = new SignatureProvider(subtleCryptoProvider);

const signature = await signatureProvider.computeSignature(
timestamp,
payload,
secret,
);

expect(
nodeCryptoProvider.secureCompare(signature, signatureHash),
).toEqual(subtleCryptoProvider.secureCompare(signature, signatureHash));

expect(nodeCryptoProvider.secureCompare(signature, 'foo')).toEqual(
subtleCryptoProvider.secureCompare(signature, 'foo'),
);
});
});
});
38 changes: 38 additions & 0 deletions src/common/crypto/CryptoProvider.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
/**
* Interface encapsulating the various crypto computations used by the library,
* allowing pluggable underlying crypto implementations.
*/
export abstract class CryptoProvider {
encoder = new TextEncoder();

/**
* Computes a SHA-256 HMAC given a secret and a payload (encoded in UTF-8).
* The output HMAC should be encoded in hexadecimal.
*
* Sample values for implementations:
* - computeHMACSignature('', 'test_secret') => 'f7f9bd47fb987337b5796fdc1fdb9ba221d0d5396814bfcaf9521f43fd8927fd'
* - computeHMACSignature('\ud83d\ude00', 'test_secret') => '837da296d05c4fe31f61d5d7ead035099d9585a5bcde87de952012a78f0b0c43
*/
abstract computeHMACSignature(payload: string, secret: string): string;

/**
* Asynchronous version of `computeHMACSignature`. Some implementations may
* only allow support async signature computation.
*
* Computes a SHA-256 HMAC given a secret and a payload (encoded in UTF-8).
* The output HMAC should be encoded in hexadecimal.
*
* Sample values for implementations:
* - computeHMACSignature('', 'test_secret') => 'f7f9bd47fb987337b5796fdc1fdb9ba221d0d5396814bfcaf9521f43fd8927fd'
* - computeHMACSignature('\ud83d\ude00', 'test_secret') => '837da296d05c4fe31f61d5d7ead035099d9585a5bcde87de952012a78f0b0c43
*/
abstract computeHMACSignatureAsync(
payload: string,
secret: string,
): Promise<string>;

/**
* Cryptographically determine whether two signatures are equal
*/
abstract secureCompare(stringA: string, stringB: string): Promise<boolean>;
}
42 changes: 42 additions & 0 deletions src/common/crypto/NodeCryptoProvider.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import * as crypto from 'crypto';
import { CryptoProvider } from './CryptoProvider';

/**
* `CryptoProvider which uses the Node `crypto` package for its computations.
*/
export class NodeCryptoProvider extends CryptoProvider {
/** @override */
computeHMACSignature(payload: string, secret: string): string {
return crypto
.createHmac('sha256', secret)
.update(payload, 'utf8')
.digest('hex');
}

/** @override */
async computeHMACSignatureAsync(
payload: string,
secret: string,
): Promise<string> {
const signature = await this.computeHMACSignature(payload, secret);
return signature;
}

/** @override */
async secureCompare(stringA: string, stringB: string): Promise<boolean> {
const bufferA = this.encoder.encode(stringA);
const bufferB = this.encoder.encode(stringB);

if (bufferA.length !== bufferB.length) {
return false;
}

// Generate a random key for HMAC
const key = crypto.randomBytes(32); // Generates a 256-bit key
const hmacA = crypto.createHmac('sha256', key).update(bufferA).digest();
const hmacB = crypto.createHmac('sha256', key).update(bufferB).digest();

// Perform a constant time comparison
return crypto.timingSafeEqual(hmacA, hmacB);
}
}
Loading

0 comments on commit 6086f7f

Please sign in to comment.