Skip to content

Commit

Permalink
feat: Add actions support (#1161)
Browse files Browse the repository at this point in the history
## Description
Introduce new methods for Actions:
- `signResponse`: Supports forming and signing actions responses from an
actions endpoint
- `verifyHeader`: Supports incoming requests from WorkOS reaching
actions endpoints

Example usage:
```typescript
app.post("/registration-action", async (req, res) => {
  const context = req.body;

  // Verify incoming workos-signature 
  try {
    await workos.actions.verifyHeader({
      payload: context,
      sigHeader: req.headers["workos-signature"] as string,
      secret: env.ACTIONS_SECRET,
    });
  } catch (err) {
    return res.status(400).json({ error: "Invalid signature" });
  }

  let verdict: "allow" | "deny";

  if (context.user_data.email.split("@")[1] === "gmail.com") {
    verdict = "deny";
  } else {
    verdict = "allow";
  }

  // Sign the outgoing response using the actions secret 
  const response = await workos.actions.signResponse({
    type: "user_registration",
    verdict,
    ...(verdict === "deny" && {
      errorMessage: "Please use a work email address",
    }),
    secret: env.ACTIONS_SECRET,
  });

  return res.json(response);
});
```

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

```
[x] Yes
```

If yes, link a related docs PR and add a docs maintainer as a reviewer.
Their approval is required.
  • Loading branch information
faroceann authored Nov 12, 2024
1 parent 13c4f7f commit 1fdf3bb
Show file tree
Hide file tree
Showing 15 changed files with 544 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'),
);
});
});
});
67 changes: 67 additions & 0 deletions src/common/crypto/SignatureProvider.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import crypto from 'crypto';
import { SubtleCryptoProvider } from './SubtleCryptoProvider';
import mockWebhook from '../../webhooks/fixtures/webhook.json';
import { SignatureProvider } from './SignatureProvider';

describe('SignatureProvider', () => {
let payload: any;
let secret: string;
let timestamp: number;
let signatureHash: string;
const signatureProvider = new SignatureProvider(new SubtleCryptoProvider());

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('verifyHeader', () => {
it('returns true when the signature is valid', async () => {
const sigHeader = `t=${timestamp}, v1=${signatureHash}`;
const options = { payload, sigHeader, secret };
const result = await signatureProvider.verifyHeader(options);
expect(result).toBeTruthy();
});
});

describe('getTimestampAndSignatureHash', () => {
it('returns the timestamp and signature when the signature is valid', () => {
const sigHeader = `t=${timestamp}, v1=${signatureHash}`;
const timestampAndSignature =
signatureProvider.getTimestampAndSignatureHash(sigHeader);

expect(timestampAndSignature).toEqual([
timestamp.toString(),
signatureHash,
]);
});
});

describe('computeSignature', () => {
it('returns the computed signature', async () => {
const signature = await signatureProvider.computeSignature(
timestamp,
payload,
secret,
);

expect(signature).toEqual(signatureHash);
});
});

describe('when in an environment that supports SubtleCrypto', () => {
it('automatically uses the subtle crypto library', () => {
// tslint:disable-next-line
expect(signatureProvider['cryptoProvider']).toBeInstanceOf(
SubtleCryptoProvider,
);
});
});
});
Loading

0 comments on commit 1fdf3bb

Please sign in to comment.