From 8e1b0251014e6ae763842443e589b1ea137ef3cb Mon Sep 17 00:00:00 2001 From: Adam Jenkins Date: Fri, 16 Oct 2026 10:37:22 -0300 Subject: [PATCH 1/2] feat(admin-portal): Add revokeLink and externalUserId on generateLink POST /portal/revoke_link shipped in the API on 2026-10-09 and reached the generated SDKs on 2026-10-15, but workos-node is not in the oagen SDK matrix, so its AdminPortal module stopped at generateLink. Mirror the Python resource: generateLink accepts externalUserId, and revokeLink revokes every link generated with that id for the organization and ends the portal sessions started from them. Co-Authored-By: Claude Fable 5.1 --- src/admin-portal/admin-portal.spec.ts | 56 ++++++++++++++++++- src/admin-portal/admin-portal.ts | 34 ++++++++++- .../interfaces/generate-link.interface.ts | 3 + .../serializers/generate-link.serializer.ts | 2 + 4 files changed, 93 insertions(+), 2 deletions(-) diff --git a/src/admin-portal/admin-portal.spec.ts b/src/admin-portal/admin-portal.spec.ts index ece354dac..7d04f64a5 100644 --- a/src/admin-portal/admin-portal.spec.ts +++ b/src/admin-portal/admin-portal.spec.ts @@ -1,5 +1,5 @@ import fetch from 'jest-fetch-mock'; -import { fetchBody, fetchOnce } from '../common/utils/test-utils'; +import { fetchBody, fetchOnce, fetchURL } from '../common/utils/test-utils'; import { WorkOS } from '../workos'; import generateLinkInvalid from './fixtures/generate-link-invalid.json'; import portalLinkResponse from './fixtures/portal-link-response.json'; @@ -193,6 +193,24 @@ describe('AdminPortal', () => { }); }); + describe('with an externalUserId', () => { + it('sends it as external_user_id', async () => { + fetchOnce(portalLinkResponse, { status: 201 }); + + await workos.adminPortal.generateLink({ + intent: GenerateLinkIntent.SSO, + organization: 'org_01EHQMYV6MBK39QC5PZXHY59C3', + externalUserId: 'user_12345', + }); + + expect(fetchBody()).toEqual({ + intent: GenerateLinkIntent.SSO, + organization: 'org_01EHQMYV6MBK39QC5PZXHY59C3', + external_user_id: 'user_12345', + }); + }); + }); + describe('with an invalid organization', () => { it('throws an error', async () => { fetchOnce(generateLinkInvalid, { @@ -217,6 +235,24 @@ describe('AdminPortal', () => { }); }); + describe('with an externalUserId', () => { + it('sends it as external_user_id', async () => { + fetchOnce(portalLinkResponse, { status: 201 }); + + await workos.adminPortal.generateLink({ + intent: GenerateLinkIntent.SSO, + organization: 'org_01EHQMYV6MBK39QC5PZXHY59C3', + externalUserId: 'user_12345', + }); + + expect(fetchBody()).toEqual({ + intent: GenerateLinkIntent.SSO, + organization: 'org_01EHQMYV6MBK39QC5PZXHY59C3', + external_user_id: 'user_12345', + }); + }); + }); + describe('with an invalid organization', () => { it('throws an error', async () => { fetchOnce(generateLinkInvalid, { @@ -241,4 +277,22 @@ describe('AdminPortal', () => { }); }); }); + + describe('revokeLink', () => { + it('sends a revoke_link request', async () => { + fetchOnce(); + + const resp = await workos.adminPortal.revokeLink({ + organization: 'org_01EHQMYV6MBK39QC5PZXHY59C3', + externalUserId: 'user_12345', + }); + + expect(fetchURL()).toContain('/portal/revoke_link'); + expect(fetchBody()).toEqual({ + organization: 'org_01EHQMYV6MBK39QC5PZXHY59C3', + external_user_id: 'user_12345', + }); + expect(resp).toBeUndefined(); + }); + }); }); diff --git a/src/admin-portal/admin-portal.ts b/src/admin-portal/admin-portal.ts index 3623ba317..3c2af90d9 100644 --- a/src/admin-portal/admin-portal.ts +++ b/src/admin-portal/admin-portal.ts @@ -10,7 +10,9 @@ export class AdminPortal { * Generate a Portal Link * * Generate a Portal Link scoped to an Organization. - * @param payload - Object containing organization. + * @param payload - Object containing organization. Pass `externalUserId` + * for every admin you may later need to deprovision; only links generated + * with one can be revoked via `revokeLink`. * @returns {Promise<{ link: string; }>} * @throws {BadRequestException} 400 * @throws 403 response from the API. @@ -24,6 +26,7 @@ export class AdminPortal { successUrl, intentOptions, adminEmails, + externalUserId, }: { intent?: GenerateLinkIntent; organization: string; @@ -36,6 +39,7 @@ export class AdminPortal { }; }; adminEmails?: string[]; + externalUserId?: string; }): Promise<{ link: string }> { const { data } = await this.workos.post('/portal/generate_link', { intent, @@ -51,8 +55,36 @@ export class AdminPortal { } : undefined, admin_emails: adminEmails, + external_user_id: externalUserId, }); return data; } + + /** + * Revoke Portal Links + * + * Revokes every Portal Link generated for the given organization with the + * given `externalUserId`, and immediately ends any Admin Portal sessions that + * were started from those links. Use this when an admin is deprovisioned from + * your application: unopened links stop working, and their already-open + * portal sessions are ended. Only links that were generated with an + * `externalUserId` can be revoked, so pass one to `generateLink` for every + * admin you may later need to deprovision. + * @param payload - Object containing organization and externalUserId. + * @throws {NotFoundException} 404 + * @throws {UnprocessableEntityException} 422 + */ + async revokeLink({ + organization, + externalUserId, + }: { + organization: string; + externalUserId: string; + }): Promise { + await this.workos.post('/portal/revoke_link', { + organization, + external_user_id: externalUserId, + }); + } } diff --git a/src/admin-portal/interfaces/generate-link.interface.ts b/src/admin-portal/interfaces/generate-link.interface.ts index b76720bf4..60a3b0d91 100644 --- a/src/admin-portal/interfaces/generate-link.interface.ts +++ b/src/admin-portal/interfaces/generate-link.interface.ts @@ -29,6 +29,8 @@ export interface GenerateLink { intentOptions?: IntentOptions; /** The email addresses of the IT admins to grant access to the Admin Portal for the given organization. Accepts up to 20 emails. */ adminEmails?: string[]; + /** The ID of the user in your application the link is generated for. The link and any Admin Portal sessions started from it can later be revoked by this ID, for example when that user is deprovisioned from your application. */ + externalUserId?: string; } export interface GenerateLinkResponse { @@ -38,4 +40,5 @@ export interface GenerateLinkResponse { intent?: GenerateLinkIntent; intent_options?: IntentOptionsResponse; admin_emails?: string[]; + external_user_id?: string; } diff --git a/src/admin-portal/serializers/generate-link.serializer.ts b/src/admin-portal/serializers/generate-link.serializer.ts index c76688537..b0a3f9b74 100644 --- a/src/admin-portal/serializers/generate-link.serializer.ts +++ b/src/admin-portal/serializers/generate-link.serializer.ts @@ -21,6 +21,7 @@ export const deserializeGenerateLink = ( ? deserializeIntentOptions(response.intent_options) : undefined, adminEmails: response.admin_emails, + externalUserId: response.external_user_id, }); export const serializeGenerateLink = ( @@ -35,4 +36,5 @@ export const serializeGenerateLink = ( ? serializeIntentOptions(model.intentOptions) : undefined, admin_emails: model.adminEmails, + external_user_id: model.externalUserId, }); From 299fbbc11f1ba118f0046d9d9d7c7a7c7dafd50c Mon Sep 17 00:00:00 2001 From: Adam Jenkins Date: Fri, 16 Oct 2026 10:40:21 -0300 Subject: [PATCH 2/2] test(admin-portal): Drop duplicated externalUserId spec, cover it in the round-trip fixture Co-Authored-By: Claude Fable 5.1 --- src/admin-portal/admin-portal.spec.ts | 18 ------------------ src/admin-portal/fixtures/generate-link.json | 3 ++- 2 files changed, 2 insertions(+), 19 deletions(-) diff --git a/src/admin-portal/admin-portal.spec.ts b/src/admin-portal/admin-portal.spec.ts index 7d04f64a5..f20eadb04 100644 --- a/src/admin-portal/admin-portal.spec.ts +++ b/src/admin-portal/admin-portal.spec.ts @@ -235,24 +235,6 @@ describe('AdminPortal', () => { }); }); - describe('with an externalUserId', () => { - it('sends it as external_user_id', async () => { - fetchOnce(portalLinkResponse, { status: 201 }); - - await workos.adminPortal.generateLink({ - intent: GenerateLinkIntent.SSO, - organization: 'org_01EHQMYV6MBK39QC5PZXHY59C3', - externalUserId: 'user_12345', - }); - - expect(fetchBody()).toEqual({ - intent: GenerateLinkIntent.SSO, - organization: 'org_01EHQMYV6MBK39QC5PZXHY59C3', - external_user_id: 'user_12345', - }); - }); - }); - describe('with an invalid organization', () => { it('throws an error', async () => { fetchOnce(generateLinkInvalid, { diff --git a/src/admin-portal/fixtures/generate-link.json b/src/admin-portal/fixtures/generate-link.json index 63bd14d7c..787c418b2 100644 --- a/src/admin-portal/fixtures/generate-link.json +++ b/src/admin-portal/fixtures/generate-link.json @@ -9,5 +9,6 @@ "provider_type": "GoogleSAML" } }, - "admin_emails": ["admin@example.com"] + "admin_emails": ["admin@example.com"], + "external_user_id": "user_12345" }