Skip to content

Commit 70a326d

Browse files
authored
Return action type (#1179)
## Description Now action returns the exact action types and support exhaustiveness checking: ```typescript const DISALLOWED_EMAIL_DOMAINS = ['gmail.com', 'yahoo.com', 'hotmail.com']; const computeSignature = async ( payload: any, timestamp: number, ): Promise<string> => workos.webhooks.computeSignature( timestamp, payload, process.env.WORKOS_ACTIONS_SECRET ?? '', ); export const POST = async (req: NextRequest) => { const body = await req.json(); const action = await workos.actions.constructAction({ payload: body, sigHeader: req.headers.get('WorkOS-Signature'), secret: process.env.WORKOS_ACTIONS_SECRET ?? '', }); switch (action.object) { case 'user_registration_action_context': const timestamp = Date.now(); let verdict: 'Allow' | 'Deny'; const emailDomain = action.userData.email.split('@')[1]; if (DISALLOWED_EMAIL_DOMAINS.includes(emailDomain)) { verdict = 'Deny'; } else { verdict = 'Allow'; } const responsePayload = { timestamp, verdict }; const response = { object: 'user_registration_action_response', payload: responsePayload, signature: await computeSignature( responsePayload, responsePayload.timestamp, ), }; return NextResponse.json(response); case 'authentication_action_context': throw new Error('Unsupported action type'); default: throw new Error('Unrecognized action type'); } }; ``` ## 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.
1 parent 4f07e49 commit 70a326d

File tree

7 files changed

+288
-23
lines changed

7 files changed

+288
-23
lines changed

src/actions/actions.spec.ts

Lines changed: 112 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import crypto from 'crypto';
22
import { WorkOS } from '../workos';
3-
import mockActionContext from './fixtures/action-context.json';
3+
import mockAuthActionContext from './fixtures/authentication-action-context.json';
4+
import mockUserRegistrationActionContext from './fixtures/user-registration-action-context.json';
45
const workos = new WorkOS('sk_test_Sz3IQjepeSWaI4cMS4ms4sMuU');
56
import { NodeCryptoProvider } from '../common/crypto';
67

@@ -11,6 +12,17 @@ describe('Actions', () => {
1112
secret = 'secret';
1213
});
1314

15+
const makeSigHeader = (payload: unknown, secret: string) => {
16+
const timestamp = Date.now() * 1000;
17+
const unhashedString = `${timestamp}.${JSON.stringify(payload)}`;
18+
const signatureHash = crypto
19+
.createHmac('sha256', secret)
20+
.update(unhashedString)
21+
.digest()
22+
.toString('hex');
23+
return `t=${timestamp}, v1=${signatureHash}`;
24+
};
25+
1426
describe('signResponse', () => {
1527
describe('type: authentication', () => {
1628
it('returns a signed response', async () => {
@@ -71,30 +83,108 @@ describe('Actions', () => {
7183
});
7284

7385
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}`,
86+
it('verifies the header', async () => {
87+
await expect(
88+
workos.actions.verifyHeader({
89+
payload: mockAuthActionContext,
90+
sigHeader: makeSigHeader(mockAuthActionContext, secret),
91+
secret,
92+
}),
93+
).resolves.not.toThrow();
94+
});
95+
96+
it('throws when the header is invalid', async () => {
97+
await expect(
98+
workos.actions.verifyHeader({
99+
payload: mockAuthActionContext,
100+
sigHeader: 't=123, v1=123',
101+
secret,
102+
}),
103+
).rejects.toThrow();
104+
});
105+
});
106+
107+
describe('constructAction', () => {
108+
it('returns an authentication action', async () => {
109+
const payload = mockAuthActionContext;
110+
const sigHeader = makeSigHeader(payload, secret);
111+
const action = await workos.actions.constructAction({
112+
payload,
113+
sigHeader,
94114
secret,
95115
});
96116

97-
expect(spy).toHaveBeenCalled();
117+
expect(action).toEqual({
118+
id: '01JATCMZJY26PQ59XT9BNT0FNN',
119+
user: {
120+
object: 'user',
121+
id: '01JATCHZVEC5EPANDPEZVM68Y9',
122+
123+
firstName: 'Jane',
124+
lastName: 'Doe',
125+
emailVerified: true,
126+
profilePictureUrl: 'https://example.com/jane.jpg',
127+
createdAt: '2024-10-22T17:12:50.746Z',
128+
updatedAt: '2024-10-22T17:12:50.746Z',
129+
},
130+
ipAddress: '50.141.123.10',
131+
userAgent: 'Mozilla/5.0',
132+
issuer: 'test',
133+
object: 'authentication_action_context',
134+
organization: {
135+
object: 'organization',
136+
id: '01JATCMZJY26PQ59XT9BNT0FNN',
137+
name: 'Foo Corp',
138+
allowProfilesOutsideOrganization: false,
139+
domains: [],
140+
createdAt: '2024-10-22T17:12:50.746Z',
141+
updatedAt: '2024-10-22T17:12:50.746Z',
142+
},
143+
organizationMembership: {
144+
object: 'organization_membership',
145+
id: '01JATCNVYCHT1SZGENR4QTXKRK',
146+
userId: '01JATCHZVEC5EPANDPEZVM68Y9',
147+
organizationId: '01JATCMZJY26PQ59XT9BNT0FNN',
148+
role: {
149+
slug: 'member',
150+
},
151+
status: 'active',
152+
createdAt: '2024-10-22T17:12:50.746Z',
153+
updatedAt: '2024-10-22T17:12:50.746Z',
154+
},
155+
});
156+
});
157+
158+
it('returns a user registration action', async () => {
159+
const payload = mockUserRegistrationActionContext;
160+
const sigHeader = makeSigHeader(payload, secret);
161+
const action = await workos.actions.constructAction({
162+
payload,
163+
sigHeader,
164+
secret,
165+
});
166+
167+
expect(action).toEqual({
168+
id: '01JATCMZJY26PQ59XT9BNT0FNN',
169+
object: 'user_registration_action_context',
170+
userData: {
171+
object: 'user_data',
172+
173+
firstName: 'Jane',
174+
lastName: 'Doe',
175+
},
176+
ipAddress: '50.141.123.10',
177+
userAgent: 'Mozilla/5.0',
178+
invitation: expect.objectContaining({
179+
object: 'invitation',
180+
id: '01JBVZWH8HJ855YZ5BWHG1WNZN',
181+
182+
expiresAt: '2024-10-22T17:12:50.746Z',
183+
createdAt: '2024-10-21T17:12:50.746Z',
184+
updatedAt: '2024-10-21T17:12:50.746Z',
185+
acceptedAt: '2024-10-22T17:13:50.746Z',
186+
}),
187+
});
98188
});
99189
});
100190
});

src/actions/actions.ts

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
11
import { SignatureProvider } from '../common/crypto';
22
import { CryptoProvider } from '../common/crypto/crypto-provider';
33
import { unreachable } from '../common/utils/unreachable';
4+
import { ActionContext, ActionPayload } from './interfaces/action.interface';
45
import {
56
AuthenticationActionResponseData,
67
ResponsePayload,
78
UserRegistrationActionResponseData,
8-
} from './interfaces/response-payload';
9+
} from './interfaces/response-payload.interface';
10+
import { deserializeAction } from './serializers/action.serializer';
911

1012
export class Actions {
1113
private signatureProvider: SignatureProvider;
@@ -67,4 +69,21 @@ export class Actions {
6769

6870
return response;
6971
}
72+
73+
async constructAction({
74+
payload,
75+
sigHeader,
76+
secret,
77+
tolerance = 300,
78+
}: {
79+
payload: unknown;
80+
sigHeader: string;
81+
secret: string;
82+
tolerance?: number;
83+
}): Promise<ActionContext> {
84+
const options = { payload, sigHeader, secret, tolerance };
85+
await this.verifyHeader(options);
86+
87+
return deserializeAction(payload as ActionPayload);
88+
}
7089
}

src/actions/fixtures/action-context.json renamed to src/actions/fixtures/authentication-action-context.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
{
2+
"id": "01JATCMZJY26PQ59XT9BNT0FNN",
23
"user": {
34
"object": "user",
45
"id": "01JATCHZVEC5EPANDPEZVM68Y9",
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
{
2+
"id": "01JATCMZJY26PQ59XT9BNT0FNN",
3+
"user_data": {
4+
"object": "user_data",
5+
"email": "[email protected]",
6+
"first_name": "Jane",
7+
"last_name": "Doe"
8+
},
9+
"ip_address": "50.141.123.10",
10+
"user_agent": "Mozilla/5.0",
11+
"object": "user_registration_action_context",
12+
"invitation": {
13+
"object": "invitation",
14+
"id": "01JBVZWH8HJ855YZ5BWHG1WNZN",
15+
"email": "[email protected]",
16+
"expires_at": "2024-10-22T17:12:50.746Z",
17+
"created_at": "2024-10-21T17:12:50.746Z",
18+
"updated_at": "2024-10-21T17:12:50.746Z",
19+
"accepted_at": "2024-10-22T17:13:50.746Z",
20+
"revoked_at": null,
21+
"organization_id": "01JBW46BTKAA98WZN8826XQ2YP",
22+
"inviter_user_id": "01JBVZWAEPWAE3YYKBVT0AF81F"
23+
}
24+
}
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
import {
2+
Organization,
3+
OrganizationResponse,
4+
} from '../../organizations/interfaces';
5+
import {
6+
Invitation,
7+
InvitationResponse,
8+
OrganizationMembership,
9+
OrganizationMembershipResponse,
10+
User,
11+
UserResponse,
12+
} from '../../user-management/interfaces';
13+
14+
interface AuthenticationActionContext {
15+
id: string;
16+
object: 'authentication_action_context';
17+
user: User;
18+
organization?: Organization;
19+
organizationMembership?: OrganizationMembership;
20+
ipAddress?: string;
21+
userAgent?: string;
22+
issuer?: string;
23+
}
24+
25+
export interface UserData {
26+
object: 'user_data';
27+
email: string;
28+
firstName: string;
29+
lastName: string;
30+
}
31+
32+
interface UserRegistrationActionContext {
33+
id: string;
34+
object: 'user_registration_action_context';
35+
userData: UserData;
36+
invitation?: Invitation;
37+
ipAddress?: string;
38+
userAgent?: string;
39+
}
40+
41+
export type ActionContext =
42+
| AuthenticationActionContext
43+
| UserRegistrationActionContext;
44+
45+
interface AuthenticationActionPayload {
46+
id: string;
47+
object: 'authentication_action_context';
48+
user: UserResponse;
49+
organization?: OrganizationResponse;
50+
organization_membership?: OrganizationMembershipResponse;
51+
ip_address?: string;
52+
user_agent?: string;
53+
issuer?: string;
54+
}
55+
56+
export interface UserDataPayload {
57+
object: 'user_data';
58+
email: string;
59+
first_name: string;
60+
last_name: string;
61+
}
62+
63+
export interface UserRegistrationActionPayload {
64+
id: string;
65+
object: 'user_registration_action_context';
66+
user_data: UserDataPayload;
67+
invitation?: InvitationResponse;
68+
ip_address?: string;
69+
user_agent?: string;
70+
}
71+
72+
export type ActionPayload =
73+
| AuthenticationActionPayload
74+
| UserRegistrationActionPayload;
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import { deserializeOrganization } from '../../organizations/serializers/organization.serializer';
2+
import {
3+
deserializeInvitation,
4+
deserializeUser,
5+
} from '../../user-management/serializers';
6+
import { deserializeOrganizationMembership } from '../../user-management/serializers/organization-membership.serializer';
7+
import {
8+
ActionContext,
9+
ActionPayload,
10+
UserData,
11+
UserDataPayload,
12+
} from '../interfaces/action.interface';
13+
14+
const deserializeUserData = (userData: UserDataPayload): UserData => {
15+
return {
16+
object: userData.object,
17+
email: userData.email,
18+
firstName: userData.first_name,
19+
lastName: userData.last_name,
20+
};
21+
};
22+
23+
export const deserializeAction = (
24+
actionPayload: ActionPayload,
25+
): ActionContext => {
26+
switch (actionPayload.object) {
27+
case 'user_registration_action_context':
28+
return {
29+
id: actionPayload.id,
30+
object: actionPayload.object,
31+
userData: deserializeUserData(actionPayload.user_data),
32+
invitation: actionPayload.invitation
33+
? deserializeInvitation(actionPayload.invitation)
34+
: undefined,
35+
36+
ipAddress: actionPayload.ip_address,
37+
userAgent: actionPayload.user_agent,
38+
};
39+
case 'authentication_action_context':
40+
return {
41+
id: actionPayload.id,
42+
object: actionPayload.object,
43+
user: deserializeUser(actionPayload.user),
44+
organization: actionPayload.organization
45+
? deserializeOrganization(actionPayload.organization)
46+
: undefined,
47+
organizationMembership: actionPayload.organization_membership
48+
? deserializeOrganizationMembership(
49+
actionPayload.organization_membership,
50+
)
51+
: undefined,
52+
ipAddress: actionPayload.ip_address,
53+
userAgent: actionPayload.user_agent,
54+
issuer: actionPayload.issuer,
55+
};
56+
}
57+
};

0 commit comments

Comments
 (0)