-
Notifications
You must be signed in to change notification settings - Fork 30
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
## 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
Showing
15 changed files
with
544 additions
and
145 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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(); | ||
}); | ||
}); | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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" | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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' }); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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'), | ||
); | ||
}); | ||
}); | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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, | ||
); | ||
}); | ||
}); | ||
}); |
Oops, something went wrong.