Skip to content

Commit 6086f7f

Browse files
faroceannPaulAsjesmattgdPaul Asjestribble
authored
Actions GA (#1172)
## 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]>
1 parent b5b7805 commit 6086f7f

19 files changed

+735
-145
lines changed

src/actions/actions.spec.ts

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
import crypto from 'crypto';
2+
import { WorkOS } from '../workos';
3+
import mockActionContext from './fixtures/action-context.json';
4+
const workos = new WorkOS('sk_test_Sz3IQjepeSWaI4cMS4ms4sMuU');
5+
import { NodeCryptoProvider } from '../common/crypto';
6+
7+
describe('Actions', () => {
8+
let secret: string;
9+
10+
beforeEach(() => {
11+
secret = 'secret';
12+
});
13+
14+
describe('signResponse', () => {
15+
describe('type: authentication', () => {
16+
it('returns a signed response', async () => {
17+
const nodeCryptoProvider = new NodeCryptoProvider();
18+
19+
const response = await workos.actions.signResponse(
20+
{
21+
type: 'authentication',
22+
verdict: 'Allow',
23+
},
24+
secret,
25+
);
26+
27+
const signedPayload = `${response.payload.timestamp}.${JSON.stringify(
28+
response.payload,
29+
)}`;
30+
31+
const expectedSig = await nodeCryptoProvider.computeHMACSignatureAsync(
32+
signedPayload,
33+
secret,
34+
);
35+
36+
expect(response.object).toEqual('authentication_action_response');
37+
expect(response.payload.verdict).toEqual('Allow');
38+
expect(response.payload.timestamp).toBeGreaterThan(0);
39+
expect(response.signature).toEqual(expectedSig);
40+
});
41+
});
42+
43+
describe('type: user_registration', () => {
44+
it('returns a signed response', async () => {
45+
const nodeCryptoProvider = new NodeCryptoProvider();
46+
47+
const response = await workos.actions.signResponse(
48+
{
49+
type: 'user_registration',
50+
verdict: 'Deny',
51+
errorMessage: 'User already exists',
52+
},
53+
secret,
54+
);
55+
56+
const signedPayload = `${response.payload.timestamp}.${JSON.stringify(
57+
response.payload,
58+
)}`;
59+
60+
const expectedSig = await nodeCryptoProvider.computeHMACSignatureAsync(
61+
signedPayload,
62+
secret,
63+
);
64+
65+
expect(response.object).toEqual('user_registration_action_response');
66+
expect(response.payload.verdict).toEqual('Deny');
67+
expect(response.payload.timestamp).toBeGreaterThan(0);
68+
expect(response.signature).toEqual(expectedSig);
69+
});
70+
});
71+
});
72+
73+
describe('verifyHeader', () => {
74+
it('aliases to the signature provider', async () => {
75+
const spy = jest.spyOn(
76+
// tslint:disable-next-line
77+
workos.actions['signatureProvider'],
78+
'verifyHeader',
79+
);
80+
81+
const timestamp = Date.now() * 1000;
82+
const unhashedString = `${timestamp}.${JSON.stringify(
83+
mockActionContext,
84+
)}`;
85+
const signatureHash = crypto
86+
.createHmac('sha256', secret)
87+
.update(unhashedString)
88+
.digest()
89+
.toString('hex');
90+
91+
await workos.actions.verifyHeader({
92+
payload: mockActionContext,
93+
sigHeader: `t=${timestamp}, v1=${signatureHash}`,
94+
secret,
95+
});
96+
97+
expect(spy).toHaveBeenCalled();
98+
});
99+
});
100+
});

src/actions/actions.ts

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
import { SignatureProvider } from '../common/crypto';
2+
import { CryptoProvider } from '../common/crypto/crypto-provider';
3+
import { unreachable } from '../common/utils/unreachable';
4+
import {
5+
AuthenticationActionResponseData,
6+
ResponsePayload,
7+
UserRegistrationActionResponseData,
8+
} from './interfaces/response-payload';
9+
10+
export class Actions {
11+
private signatureProvider: SignatureProvider;
12+
13+
constructor(cryptoProvider: CryptoProvider) {
14+
this.signatureProvider = new SignatureProvider(cryptoProvider);
15+
}
16+
17+
private get computeSignature() {
18+
return this.signatureProvider.computeSignature.bind(this.signatureProvider);
19+
}
20+
21+
get verifyHeader() {
22+
return this.signatureProvider.verifyHeader.bind(this.signatureProvider);
23+
}
24+
25+
serializeType(
26+
type:
27+
| AuthenticationActionResponseData['type']
28+
| UserRegistrationActionResponseData['type'],
29+
) {
30+
switch (type) {
31+
case 'authentication':
32+
return 'authentication_action_response';
33+
case 'user_registration':
34+
return 'user_registration_action_response';
35+
default:
36+
return unreachable(type);
37+
}
38+
}
39+
40+
async signResponse(
41+
data: AuthenticationActionResponseData | UserRegistrationActionResponseData,
42+
secret: string,
43+
) {
44+
let errorMessage: string | undefined;
45+
const { verdict, type } = data;
46+
47+
if (verdict === 'Deny' && data.errorMessage) {
48+
errorMessage = data.errorMessage;
49+
}
50+
51+
const responsePayload: ResponsePayload = {
52+
timestamp: Date.now(),
53+
verdict,
54+
...(verdict === 'Deny' &&
55+
data.errorMessage && { error_message: errorMessage }),
56+
};
57+
58+
const response = {
59+
object: this.serializeType(type),
60+
payload: responsePayload,
61+
signature: await this.computeSignature(
62+
responsePayload.timestamp,
63+
responsePayload,
64+
secret,
65+
),
66+
};
67+
68+
return response;
69+
}
70+
}
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
{
2+
"user": {
3+
"object": "user",
4+
"id": "01JATCHZVEC5EPANDPEZVM68Y9",
5+
"email": "[email protected]",
6+
"first_name": "Jane",
7+
"last_name": "Doe",
8+
"email_verified": true,
9+
"profile_picture_url": "https://example.com/jane.jpg",
10+
"created_at": "2024-10-22T17:12:50.746Z",
11+
"updated_at": "2024-10-22T17:12:50.746Z"
12+
},
13+
"ip_address": "50.141.123.10",
14+
"user_agent": "Mozilla/5.0",
15+
"issuer": "test",
16+
"object": "authentication_action_context",
17+
"organization": {
18+
"object": "organization",
19+
"id": "01JATCMZJY26PQ59XT9BNT0FNN",
20+
"name": "Foo Corp",
21+
"allow_profiles_outside_organization": false,
22+
"domains": [],
23+
"lookup_key": "my-key",
24+
"created_at": "2024-10-22T17:12:50.746Z",
25+
"updated_at": "2024-10-22T17:12:50.746Z"
26+
},
27+
"organization_membership": {
28+
"object": "organization_membership",
29+
"id": "01JATCNVYCHT1SZGENR4QTXKRK",
30+
"user_id": "01JATCHZVEC5EPANDPEZVM68Y9",
31+
"organization_id": "01JATCMZJY26PQ59XT9BNT0FNN",
32+
"role": {
33+
"slug": "member"
34+
},
35+
"status": "active",
36+
"created_at": "2024-10-22T17:12:50.746Z",
37+
"updated_at": "2024-10-22T17:12:50.746Z"
38+
}
39+
}
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
export interface ResponsePayload {
2+
timestamp: number;
3+
verdict?: 'Allow' | 'Deny';
4+
errorMessage?: string;
5+
}
6+
7+
interface AllowResponseData {
8+
verdict: 'Allow';
9+
}
10+
11+
interface DenyResponseData {
12+
verdict: 'Deny';
13+
errorMessage?: string;
14+
}
15+
16+
export type AuthenticationActionResponseData =
17+
| (AllowResponseData & { type: 'authentication' })
18+
| (DenyResponseData & { type: 'authentication' });
19+
20+
export type UserRegistrationActionResponseData =
21+
| (AllowResponseData & { type: 'user_registration' })
22+
| (DenyResponseData & { type: 'user_registration' });
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
import crypto from 'crypto';
2+
import { NodeCryptoProvider } from './NodeCryptoProvider';
3+
import { SubtleCryptoProvider } from './SubtleCryptoProvider';
4+
import mockWebhook from '../../webhooks/fixtures/webhook.json';
5+
import { SignatureProvider } from './SignatureProvider';
6+
7+
describe('CryptoProvider', () => {
8+
let payload: any;
9+
let secret: string;
10+
let timestamp: number;
11+
let signatureHash: string;
12+
13+
beforeEach(() => {
14+
payload = mockWebhook;
15+
secret = 'secret';
16+
timestamp = Date.now() * 1000;
17+
const unhashedString = `${timestamp}.${JSON.stringify(payload)}`;
18+
signatureHash = crypto
19+
.createHmac('sha256', secret)
20+
.update(unhashedString)
21+
.digest()
22+
.toString('hex');
23+
});
24+
25+
describe('when computing HMAC signature', () => {
26+
it('returns the same for the Node crypto and Web Crypto versions', async () => {
27+
const nodeCryptoProvider = new NodeCryptoProvider();
28+
const subtleCryptoProvider = new SubtleCryptoProvider();
29+
30+
const stringifiedPayload = JSON.stringify(payload);
31+
const payloadHMAC = `${timestamp}.${stringifiedPayload}`;
32+
33+
const nodeCompare = await nodeCryptoProvider.computeHMACSignatureAsync(
34+
payloadHMAC,
35+
secret,
36+
);
37+
const subtleCompare =
38+
await subtleCryptoProvider.computeHMACSignatureAsync(
39+
payloadHMAC,
40+
secret,
41+
);
42+
43+
expect(nodeCompare).toEqual(subtleCompare);
44+
});
45+
});
46+
47+
describe('when securely comparing', () => {
48+
it('returns the same for the Node crypto and Web Crypto versions', async () => {
49+
const nodeCryptoProvider = new NodeCryptoProvider();
50+
const subtleCryptoProvider = new SubtleCryptoProvider();
51+
const signatureProvider = new SignatureProvider(subtleCryptoProvider);
52+
53+
const signature = await signatureProvider.computeSignature(
54+
timestamp,
55+
payload,
56+
secret,
57+
);
58+
59+
expect(
60+
nodeCryptoProvider.secureCompare(signature, signatureHash),
61+
).toEqual(subtleCryptoProvider.secureCompare(signature, signatureHash));
62+
63+
expect(nodeCryptoProvider.secureCompare(signature, 'foo')).toEqual(
64+
subtleCryptoProvider.secureCompare(signature, 'foo'),
65+
);
66+
});
67+
});
68+
});
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
/**
2+
* Interface encapsulating the various crypto computations used by the library,
3+
* allowing pluggable underlying crypto implementations.
4+
*/
5+
export abstract class CryptoProvider {
6+
encoder = new TextEncoder();
7+
8+
/**
9+
* Computes a SHA-256 HMAC given a secret and a payload (encoded in UTF-8).
10+
* The output HMAC should be encoded in hexadecimal.
11+
*
12+
* Sample values for implementations:
13+
* - computeHMACSignature('', 'test_secret') => 'f7f9bd47fb987337b5796fdc1fdb9ba221d0d5396814bfcaf9521f43fd8927fd'
14+
* - computeHMACSignature('\ud83d\ude00', 'test_secret') => '837da296d05c4fe31f61d5d7ead035099d9585a5bcde87de952012a78f0b0c43
15+
*/
16+
abstract computeHMACSignature(payload: string, secret: string): string;
17+
18+
/**
19+
* Asynchronous version of `computeHMACSignature`. Some implementations may
20+
* only allow support async signature computation.
21+
*
22+
* Computes a SHA-256 HMAC given a secret and a payload (encoded in UTF-8).
23+
* The output HMAC should be encoded in hexadecimal.
24+
*
25+
* Sample values for implementations:
26+
* - computeHMACSignature('', 'test_secret') => 'f7f9bd47fb987337b5796fdc1fdb9ba221d0d5396814bfcaf9521f43fd8927fd'
27+
* - computeHMACSignature('\ud83d\ude00', 'test_secret') => '837da296d05c4fe31f61d5d7ead035099d9585a5bcde87de952012a78f0b0c43
28+
*/
29+
abstract computeHMACSignatureAsync(
30+
payload: string,
31+
secret: string,
32+
): Promise<string>;
33+
34+
/**
35+
* Cryptographically determine whether two signatures are equal
36+
*/
37+
abstract secureCompare(stringA: string, stringB: string): Promise<boolean>;
38+
}
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import * as crypto from 'crypto';
2+
import { CryptoProvider } from './CryptoProvider';
3+
4+
/**
5+
* `CryptoProvider which uses the Node `crypto` package for its computations.
6+
*/
7+
export class NodeCryptoProvider extends CryptoProvider {
8+
/** @override */
9+
computeHMACSignature(payload: string, secret: string): string {
10+
return crypto
11+
.createHmac('sha256', secret)
12+
.update(payload, 'utf8')
13+
.digest('hex');
14+
}
15+
16+
/** @override */
17+
async computeHMACSignatureAsync(
18+
payload: string,
19+
secret: string,
20+
): Promise<string> {
21+
const signature = await this.computeHMACSignature(payload, secret);
22+
return signature;
23+
}
24+
25+
/** @override */
26+
async secureCompare(stringA: string, stringB: string): Promise<boolean> {
27+
const bufferA = this.encoder.encode(stringA);
28+
const bufferB = this.encoder.encode(stringB);
29+
30+
if (bufferA.length !== bufferB.length) {
31+
return false;
32+
}
33+
34+
// Generate a random key for HMAC
35+
const key = crypto.randomBytes(32); // Generates a 256-bit key
36+
const hmacA = crypto.createHmac('sha256', key).update(bufferA).digest();
37+
const hmacB = crypto.createHmac('sha256', key).update(bufferB).digest();
38+
39+
// Perform a constant time comparison
40+
return crypto.timingSafeEqual(hmacA, hmacB);
41+
}
42+
}

0 commit comments

Comments
 (0)