diff --git a/README.md b/README.md index 75abe11f4..438868c20 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,60 @@ import { WorkOS } from '@workos-inc/node'; const workos = new WorkOS('sk_1234'); ``` +## Public Client Mode (Browser/Mobile/CLI) + +For apps that can't securely store secrets, initialize with just a client ID: + +```ts +import { WorkOS } from '@workos-inc/node'; + +const workos = new WorkOS({ clientId: 'client_...' }); // No API key needed + +// Generate auth URL with automatic PKCE +const { url, codeVerifier } = + await workos.userManagement.getAuthorizationUrlWithPKCE({ + provider: 'authkit', + redirectUri: 'myapp://callback', + clientId: 'client_...', + }); + +// After user authenticates, exchange code for tokens +const { accessToken, refreshToken } = + await workos.userManagement.authenticateWithCode({ + code: authorizationCode, + codeVerifier, + clientId: 'client_...', + }); +``` + +> [!IMPORTANT] +> Store `codeVerifier` securely on-device between generating the auth URL and handling the callback. For mobile apps, use platform secure storage (iOS Keychain, Android Keystore). For CLI apps, consider OS credential storage. The verifier must survive app restarts during the auth flow. + +See the [AuthKit documentation](https://workos.com/docs/authkit) for details on PKCE authentication. + +### PKCE with Confidential Clients + +Server-side apps can also use PKCE alongside the client secret for defense in depth (recommended by OAuth 2.1): + +```ts +const workos = new WorkOS('sk_...'); // With API key + +// Use PKCE even with API key for additional security +const { url, codeVerifier } = + await workos.userManagement.getAuthorizationUrlWithPKCE({ + provider: 'authkit', + redirectUri: 'https://example.com/callback', + clientId: 'client_...', + }); + +// Both client_secret AND code_verifier will be sent +const { accessToken } = await workos.userManagement.authenticateWithCode({ + code: authorizationCode, + codeVerifier, + clientId: 'client_...', +}); +``` + ## SDK Versioning For our SDKs WorkOS follows a Semantic Versioning ([SemVer](https://semver.org/)) process where all releases will have a version X.Y.Z (like 1.0.0) pattern wherein Z would be a bug fix (e.g., 1.0.1), Y would be a minor release (1.1.0) and X would be a major release (2.0.0). We permit any breaking changes to only be released in major versions and strongly recommend reading changelogs before making any major version upgrades. diff --git a/package-lock.json b/package-lock.json index 0b2bc1b63..4a40c47d6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -25,6 +25,7 @@ "@types/qs": "^6.14.0", "@typescript-eslint/parser": "^8.46.0", "babel-jest": "^30.2.0", + "baseline-browser-mapping": "^2.9.11", "eslint": "^9.37.0", "eslint-plugin-jest": "^29.0.1", "eslint-plugin-n": "^17.23.1", @@ -192,6 +193,7 @@ "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", @@ -4707,6 +4709,7 @@ "integrity": "sha512-sbaQ27XBUopBkRiuY/P9sWGOWUW4rl8fDoHIUmLpZd8uldsTyB4/Zg6bWTegPoTLnKj9Hqgn3QD6cjPNB32Odw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/regexpp": "^4.10.0", "@typescript-eslint/scope-manager": "8.46.3", @@ -4747,6 +4750,7 @@ "integrity": "sha512-6m1I5RmHBGTnUGS113G04DMu3CpSdxCAU/UvtjNWL4Nuf3MW9tQhiJqRlHzChIkhy6kZSAQmc+I1bcGjE3yNKg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.46.3", "@typescript-eslint/types": "8.46.3", @@ -5228,6 +5232,7 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -5538,9 +5543,9 @@ "license": "MIT" }, "node_modules/baseline-browser-mapping": { - "version": "2.8.25", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.25.tgz", - "integrity": "sha512-2NovHVesVF5TXefsGX1yzx1xgr7+m9JQenvz6FQY3qd+YXkKkYiv+vTCc7OriP9mcDZpTC5mAOYN4ocd29+erA==", + "version": "2.9.11", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.11.tgz", + "integrity": "sha512-Sg0xJUNDU1sJNGdfGWhVHX0kkZ+HWcvmVymJbj6NSgZZmW/8S9Y2HQ5euytnIgakgxN6papOAWiwDo1ctFDcoQ==", "dev": true, "license": "Apache-2.0", "bin": { @@ -5600,6 +5605,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.8.19", "caniuse-lite": "^1.0.30001751", @@ -6587,6 +6593,7 @@ "integrity": "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -7898,6 +7905,7 @@ "integrity": "sha512-F26gjC0yWN8uAA5m5Ss8ZQf5nDHWGlN/xWZIh8S5SRbsEKBovwZhxGd6LJlbZYxBgCYOtreSUyb8hpXyGC5O4A==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@jest/core": "30.2.0", "@jest/types": "30.2.0", @@ -8904,6 +8912,7 @@ "integrity": "sha512-jcByLnIFkd5gSXZmjNvS1TlmRhCXZjIzHYlaGkPlLIekG55JDR2Z4va9tZwCiP+/RDERiNhMOFu01xd6O5ct1Q==", "dev": true, "license": "MIT", + "peer": true, "bin": { "marked": "bin/marked.js" }, @@ -10091,6 +10100,7 @@ "integrity": "sha512-Qd9c2p0XKZdgT5AYd+KgAMggJ8ZmCs3JnS9PTMWkyUfteKlfmKtxJbWTHkVakxwXs1Ub7jrRYVeFeF7N0sQxyw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@oxc-project/types": "=0.101.0", "@rolldown/pluginutils": "1.0.0-beta.53" @@ -10893,6 +10903,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -11218,6 +11229,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" diff --git a/package.json b/package.json index 3ba9c3df0..80968b8a5 100644 --- a/package.json +++ b/package.json @@ -57,6 +57,7 @@ "@types/qs": "^6.14.0", "@typescript-eslint/parser": "^8.46.0", "babel-jest": "^30.2.0", + "baseline-browser-mapping": "^2.9.11", "eslint": "^9.37.0", "eslint-plugin-jest": "^29.0.1", "eslint-plugin-n": "^17.23.1", @@ -110,17 +111,6 @@ }, "default": "./lib/index.js" }, - "./client": { - "import": { - "types": "./lib/index.client.d.ts", - "default": "./lib/index.client.js" - }, - "require": { - "types": "./lib/index.client.d.cts", - "default": "./lib/index.client.cjs" - }, - "default": "./lib/index.client.js" - }, "./worker": { "import": { "types": "./lib/index.worker.d.ts", diff --git a/src/client/index.ts b/src/client/index.ts deleted file mode 100644 index 96cae820e..000000000 --- a/src/client/index.ts +++ /dev/null @@ -1,18 +0,0 @@ -/** - * Client methods that can be used without a WorkOS API key. - * These are OAuth client operations suitable for PKCE flows. - */ - -// User Management client methods and types -export * as userManagement from './user-management'; - -// SSO client methods and types -export * as sso from './sso'; - -// Re-export specific types for convenience -export type { - AuthorizationURLOptions as UserManagementAuthorizationURLOptions, - LogoutURLOptions, -} from './user-management'; - -export type { SSOAuthorizationURLOptions } from './sso'; diff --git a/src/client/sso.spec.ts b/src/client/sso.spec.ts deleted file mode 100644 index 47cad76ff..000000000 --- a/src/client/sso.spec.ts +++ /dev/null @@ -1,115 +0,0 @@ -import { getAuthorizationUrl } from './sso'; - -describe('Public SSO Methods', () => { - describe('getAuthorizationUrl', () => { - it('builds correct URL with provider', () => { - const url = getAuthorizationUrl({ - clientId: 'client_123', - redirectUri: 'https://app.com/callback', - provider: 'GoogleOAuth', - }); - - expect(url).toContain('https://api.workos.com/sso/authorize'); - expect(url).toContain('client_id=client_123'); - expect(url).toContain('redirect_uri=https%3A%2F%2Fapp.com%2Fcallback'); - expect(url).toContain('provider=GoogleOAuth'); - expect(url).toContain('response_type=code'); - }); - - it('builds URL with organization', () => { - const url = getAuthorizationUrl({ - clientId: 'client_123', - redirectUri: 'https://app.com/callback', - organization: 'org_123', - }); - - expect(url).toContain('organization=org_123'); - }); - - it('builds URL with connection', () => { - const url = getAuthorizationUrl({ - clientId: 'client_123', - redirectUri: 'https://app.com/callback', - connection: 'conn_123', - }); - - expect(url).toContain('connection=conn_123'); - }); - - it('includes state parameter', () => { - const url = getAuthorizationUrl({ - clientId: 'client_123', - redirectUri: 'https://app.com/callback', - provider: 'GoogleOAuth', - state: 'custom state', - }); - - expect(url).toContain('state=custom+state'); - }); - - it('includes domainHint and loginHint', () => { - const url = getAuthorizationUrl({ - clientId: 'client_123', - redirectUri: 'https://app.com/callback', - provider: 'GoogleOAuth', - domainHint: 'example.com', - loginHint: 'user@example.com', - }); - - expect(url).toContain('domain_hint=example.com'); - expect(url).toContain('login_hint=user%40example.com'); - }); - - it('includes provider scopes', () => { - const url = getAuthorizationUrl({ - clientId: 'client_123', - redirectUri: 'https://app.com/callback', - provider: 'GoogleOAuth', - providerScopes: ['read_api', 'read_repository'], - }); - - expect(url).toContain('provider_scopes=read_api'); - expect(url).toContain('provider_scopes=read_repository'); - }); - - it('includes provider query params', () => { - const url = getAuthorizationUrl({ - clientId: 'client_123', - redirectUri: 'https://app.com/callback', - provider: 'GoogleOAuth', - providerQueryParams: { - foo: 'bar', - baz: 123, - bool: true, - }, - }); - - expect(url).toContain('provider_query_params%5Bfoo%5D=bar'); - expect(url).toContain('provider_query_params%5Bbaz%5D=123'); - expect(url).toContain('provider_query_params%5Bbool%5D=true'); - }); - - it('uses custom baseURL when provided', () => { - const url = getAuthorizationUrl({ - clientId: 'client_123', - redirectUri: 'https://app.com/callback', - provider: 'GoogleOAuth', - baseURL: 'https://api.workos.dev', - }); - - expect(url).toContain('https://api.workos.dev/sso/authorize'); - }); - - it('throws error when no provider, connection, or organization specified', () => { - expect(() => { - // @ts-expect-error Testing runtime validation with invalid input - getAuthorizationUrl({ - clientId: 'client_123', - redirectUri: 'https://app.com/callback', - }); - }).toThrow( - "Incomplete arguments. Need to specify either a 'connection', 'organization', or 'provider'.", - ); - }); - }); -}); diff --git a/src/client/sso.ts b/src/client/sso.ts deleted file mode 100644 index 48335a392..000000000 --- a/src/client/sso.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { toQueryString } from '../common/utils/query-string'; -import type { SSOAuthorizationURLOptions as BaseSSOAuthorizationURLOptions } from '../sso/interfaces'; - -// Extend the base options to include baseURL for internal use -export type SSOAuthorizationURLOptions = BaseSSOAuthorizationURLOptions & { - baseURL?: string; -}; - -/** - * Generates the authorization URL for SSO authentication. - * Does not require an API key, suitable for OAuth client operations. - * - * @param options - SSO authorization URL options - * @returns The authorization URL as a string - * @throws Error if required arguments are missing - */ -export function getAuthorizationUrl( - options: SSOAuthorizationURLOptions, -): string { - const { - connection, - clientId, - domainHint, - loginHint, - organization, - provider, - providerQueryParams, - providerScopes, - redirectUri, - state, - baseURL = 'https://api.workos.com', - } = options; - - if (!provider && !connection && !organization) { - throw new Error( - `Incomplete arguments. Need to specify either a 'connection', 'organization', or 'provider'.`, - ); - } - - const query = toQueryString({ - connection, - organization, - domain_hint: domainHint, - login_hint: loginHint, - provider, - provider_query_params: providerQueryParams, - provider_scopes: providerScopes, - client_id: clientId, - redirect_uri: redirectUri, - response_type: 'code', - state, - }); - - return `${baseURL}/sso/authorize?${query}`; -} diff --git a/src/client/user-management.spec.ts b/src/client/user-management.spec.ts deleted file mode 100644 index 96312f23e..000000000 --- a/src/client/user-management.spec.ts +++ /dev/null @@ -1,202 +0,0 @@ -import { - getAuthorizationUrl, - getLogoutUrl, - getJwksUrl, -} from './user-management'; - -describe('Public User Management Methods', () => { - describe('getAuthorizationUrl', () => { - it('builds correct URL with required params', () => { - const url = getAuthorizationUrl({ - clientId: 'client_123', - redirectUri: 'https://app.com/callback', - provider: 'authkit', - }); - - expect(url).toContain('https://api.workos.com/user_management/authorize'); - expect(url).toContain('client_id=client_123'); - expect(url).toContain('redirect_uri=https%3A%2F%2Fapp.com%2Fcallback'); - expect(url).toContain('provider=authkit'); - expect(url).toContain('response_type=code'); - }); - - it('builds URL with organization ID', () => { - const url = getAuthorizationUrl({ - clientId: 'client_123', - redirectUri: 'https://app.com/callback', - organizationId: 'org_123', - }); - - expect(url).toContain('organization_id=org_123'); - }); - - it('builds URL with connection ID', () => { - const url = getAuthorizationUrl({ - clientId: 'client_123', - redirectUri: 'https://app.com/callback', - connectionId: 'conn_123', - }); - - expect(url).toContain('connection_id=conn_123'); - }); - - it('includes code challenge for PKCE', () => { - const url = getAuthorizationUrl({ - clientId: 'client_123', - redirectUri: 'https://app.com/callback', - provider: 'authkit', - codeChallenge: 'challenge_123', - codeChallengeMethod: 'S256', - }); - - expect(url).toContain('code_challenge=challenge_123'); - expect(url).toContain('code_challenge_method=S256'); - }); - - it('includes screenHint for authkit provider', () => { - const url = getAuthorizationUrl({ - clientId: 'client_123', - redirectUri: 'https://app.com/callback', - provider: 'authkit', - screenHint: 'sign-up', - }); - - expect(url).toContain('screen_hint=sign-up'); - }); - - it('throws error for screenHint with non-authkit provider', () => { - expect(() => { - getAuthorizationUrl({ - clientId: 'client_123', - redirectUri: 'https://app.com/callback', - provider: 'GoogleOAuth', - screenHint: 'sign-up', - }); - }).toThrow("'screenHint' is only supported for 'authkit' provider"); - }); - - it('includes state parameter', () => { - const url = getAuthorizationUrl({ - clientId: 'client_123', - redirectUri: 'https://app.com/callback', - provider: 'authkit', - state: 'custom state', - }); - - expect(url).toContain('state=custom+state'); - }); - - it('includes provider scopes', () => { - const url = getAuthorizationUrl({ - clientId: 'client_123', - redirectUri: 'https://app.com/callback', - provider: 'GoogleOAuth', - providerScopes: ['read_api', 'read_repository'], - }); - - expect(url).toContain('provider_scopes=read_api'); - expect(url).toContain('provider_scopes=read_repository'); - }); - - it('includes provider query params', () => { - const url = getAuthorizationUrl({ - clientId: 'client_123', - redirectUri: 'https://app.com/callback', - provider: 'GoogleOAuth', - providerQueryParams: { - foo: 'bar', - baz: 123, - bool: true, - }, - }); - - expect(url).toContain('provider_query_params%5Bfoo%5D=bar'); - expect(url).toContain('provider_query_params%5Bbaz%5D=123'); - expect(url).toContain('provider_query_params%5Bbool%5D=true'); - }); - - it('uses custom baseURL when provided', () => { - const url = getAuthorizationUrl({ - clientId: 'client_123', - redirectUri: 'https://app.com/callback', - provider: 'authkit', - baseURL: 'https://api.workos.dev', - }); - - expect(url).toContain('https://api.workos.dev/user_management/authorize'); - }); - - it('throws error when no provider, connection, or organization specified', () => { - expect(() => { - getAuthorizationUrl({ - clientId: 'client_123', - redirectUri: 'https://app.com/callback', - }); - }).toThrow( - "Incomplete arguments. Need to specify either a 'connectionId', 'organizationId', or 'provider'.", - ); - }); - }); - - describe('getLogoutUrl', () => { - it('builds correct logout URL', () => { - const url = getLogoutUrl({ - sessionId: 'session_123', - }); - - expect(url).toBe( - 'https://api.workos.com/user_management/sessions/logout?session_id=session_123', - ); - }); - - it('includes returnTo parameter when provided', () => { - const url = getLogoutUrl({ - sessionId: 'session_123', - returnTo: 'https://app.com/home', - }); - - expect(url).toBe( - 'https://api.workos.com/user_management/sessions/logout?session_id=session_123&return_to=https%3A%2F%2Fapp.com%2Fhome', - ); - }); - - it('uses custom baseURL when provided', () => { - const url = getLogoutUrl({ - sessionId: 'session_123', - baseURL: 'https://api.workos.dev', - }); - - expect(url).toBe( - 'https://api.workos.dev/user_management/sessions/logout?session_id=session_123', - ); - }); - - it('throws error when sessionId is not provided', () => { - expect(() => { - getLogoutUrl({ - sessionId: '', - }); - }).toThrow("Incomplete arguments. Need to specify 'sessionId'."); - }); - }); - - describe('getJwksUrl', () => { - it('builds correct JWKS URL', () => { - const url = getJwksUrl('client_123'); - - expect(url).toBe('https://api.workos.com/sso/jwks/client_123'); - }); - - it('uses custom baseURL when provided', () => { - const url = getJwksUrl('client_123', 'https://api.workos.dev'); - - expect(url).toBe('https://api.workos.dev/sso/jwks/client_123'); - }); - - it('throws error when clientId is not provided', () => { - expect(() => { - getJwksUrl(''); - }).toThrow('clientId must be a valid clientId'); - }); - }); -}); diff --git a/src/client/user-management.ts b/src/client/user-management.ts deleted file mode 100644 index 3cb0c759d..000000000 --- a/src/client/user-management.ts +++ /dev/null @@ -1,133 +0,0 @@ -import { toQueryString } from '../common/utils/query-string'; - -// Re-export necessary interfaces for client use -export interface AuthorizationURLOptions { - clientId: string; - codeChallenge?: string; - codeChallengeMethod?: 'S256'; - connectionId?: string; - organizationId?: string; - domainHint?: string; - loginHint?: string; - provider?: string; - providerQueryParams?: Record; - providerScopes?: string[]; - prompt?: string; - redirectUri: string; - state?: string; - screenHint?: 'sign-up' | 'sign-in'; -} - -export interface LogoutURLOptions { - sessionId: string; - returnTo?: string; -} - -/** - * Generates the authorization URL for OAuth client authentication. - * Suitable for PKCE flows and other OAuth client operations that don't require an API key. - * - * @param options - Authorization URL options - * @returns The authorization URL as a string - * @throws TypeError if required arguments are missing - */ -export function getAuthorizationUrl( - options: AuthorizationURLOptions & { baseURL?: string }, -): string { - const { - connectionId, - codeChallenge, - codeChallengeMethod, - clientId, - domainHint, - loginHint, - organizationId, - provider, - providerQueryParams, - providerScopes, - prompt, - redirectUri, - state, - screenHint, - baseURL = 'https://api.workos.com', - } = options; - - if (!provider && !connectionId && !organizationId) { - throw new TypeError( - `Incomplete arguments. Need to specify either a 'connectionId', 'organizationId', or 'provider'.`, - ); - } - - if (provider !== 'authkit' && screenHint) { - throw new TypeError( - `'screenHint' is only supported for 'authkit' provider`, - ); - } - - const query = toQueryString({ - connection_id: connectionId, - code_challenge: codeChallenge, - code_challenge_method: codeChallengeMethod, - organization_id: organizationId, - domain_hint: domainHint, - login_hint: loginHint, - provider, - provider_query_params: providerQueryParams, - provider_scopes: providerScopes, - prompt, - client_id: clientId, - redirect_uri: redirectUri, - response_type: 'code', - state, - screen_hint: screenHint, - }); - - return `${baseURL}/user_management/authorize?${query}`; -} - -/** - * Generates the logout URL for ending a user session. - * This method is safe to use in browser environments as it doesn't require an API key. - * - * @param options - Logout URL options - * @returns The logout URL as a string - * @throws TypeError if sessionId is not provided - */ -export function getLogoutUrl( - options: LogoutURLOptions & { baseURL?: string }, -): string { - const { sessionId, returnTo, baseURL = 'https://api.workos.com' } = options; - - if (!sessionId) { - throw new TypeError(`Incomplete arguments. Need to specify 'sessionId'.`); - } - - const url = new URL('/user_management/sessions/logout', baseURL); - - url.searchParams.set('session_id', sessionId); - if (returnTo) { - url.searchParams.set('return_to', returnTo); - } - - return url.toString(); -} - -/** - * Gets the JWKS (JSON Web Key Set) URL for a given client ID. - * Does not require an API key, returns the public JWKS endpoint. - * - * @param clientId - The WorkOS client ID - * @param baseURL - Optional base URL for the API (defaults to https://api.workos.com) - * @returns The JWKS URL as a string - * @throws TypeError if clientId is not provided - */ -export function getJwksUrl( - clientId: string, - baseURL = 'https://api.workos.com', -): string { - if (!clientId) { - throw new TypeError('clientId must be a valid clientId'); - } - - return `${baseURL}/sso/jwks/${clientId}`; -} diff --git a/src/common/exceptions/api-key-required.exception.ts b/src/common/exceptions/api-key-required.exception.ts new file mode 100644 index 000000000..b2b6a7f1a --- /dev/null +++ b/src/common/exceptions/api-key-required.exception.ts @@ -0,0 +1,14 @@ +export class ApiKeyRequiredException extends Error { + readonly status = 403; + readonly name = 'ApiKeyRequiredException'; + readonly path: string; + + constructor(path: string) { + super( + `API key required for "${path}". ` + + `For server-side apps, initialize with: new WorkOS("sk_..."). ` + + `For browser/mobile/CLI apps, use authenticateWithCodeAndVerifier() and authenticateWithRefreshToken() which work without an API key.`, + ); + this.path = path; + } +} diff --git a/src/common/exceptions/index.ts b/src/common/exceptions/index.ts index 845f0d6ed..a3bdc7575 100644 --- a/src/common/exceptions/index.ts +++ b/src/common/exceptions/index.ts @@ -1,3 +1,4 @@ +export * from './api-key-required.exception'; export * from './generic-server.exception'; export * from './bad-request.exception'; export * from './no-api-key-provided.exception'; diff --git a/src/common/interfaces/get-options.interface.ts b/src/common/interfaces/get-options.interface.ts index 2acc74765..93c7fd1d5 100644 --- a/src/common/interfaces/get-options.interface.ts +++ b/src/common/interfaces/get-options.interface.ts @@ -2,4 +2,6 @@ export interface GetOptions { query?: Record; accessToken?: string; warrantToken?: string; + /** Skip API key requirement check (for PKCE-safe methods) */ + skipApiKeyCheck?: boolean; } diff --git a/src/common/interfaces/post-options.interface.ts b/src/common/interfaces/post-options.interface.ts index 6b4920a98..7c9120c86 100644 --- a/src/common/interfaces/post-options.interface.ts +++ b/src/common/interfaces/post-options.interface.ts @@ -2,4 +2,6 @@ export interface PostOptions { query?: { [key: string]: any }; idempotencyKey?: string; warrantToken?: string; + /** Skip API key requirement check (for PKCE-safe methods) */ + skipApiKeyCheck?: boolean; } diff --git a/src/common/interfaces/put-options.interface.ts b/src/common/interfaces/put-options.interface.ts index 9063235be..1ac3c5863 100644 --- a/src/common/interfaces/put-options.interface.ts +++ b/src/common/interfaces/put-options.interface.ts @@ -1,4 +1,6 @@ export interface PutOptions { query?: { [key: string]: any }; idempotencyKey?: string; + /** Skip API key requirement check (for PKCE-safe methods) */ + skipApiKeyCheck?: boolean; } diff --git a/src/common/interfaces/workos-options.interface.ts b/src/common/interfaces/workos-options.interface.ts index f750942b3..1bc8ad035 100644 --- a/src/common/interfaces/workos-options.interface.ts +++ b/src/common/interfaces/workos-options.interface.ts @@ -1,6 +1,7 @@ import { AppInfo } from './app-info.interface'; export interface WorkOSOptions { + apiKey?: string; apiHostname?: string; https?: boolean; port?: number; diff --git a/src/index.client.spec.ts b/src/index.client.spec.ts deleted file mode 100644 index 5c8ff4909..000000000 --- a/src/index.client.spec.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { userManagement, sso } from './index.client'; - -describe('Client Exports', () => { - describe('userManagement exports', () => { - it('should generate authorization URLs correctly', () => { - const url = userManagement.getAuthorizationUrl({ - clientId: 'client_123', - redirectUri: 'https://example.com/callback', - provider: 'authkit', - }); - - expect(url).toContain('client_id=client_123'); - expect(url).toContain( - 'redirect_uri=https%3A%2F%2Fexample.com%2Fcallback', - ); - expect(url).toContain('provider=authkit'); - }); - - it('should generate logout URLs correctly', () => { - const url = userManagement.getLogoutUrl({ - sessionId: 'session_123', - returnTo: 'https://example.com', - }); - - expect(url).toContain('session_id=session_123'); - expect(url).toContain('return_to=https%3A%2F%2Fexample.com'); - }); - - it('should generate JWKS URLs correctly', () => { - const url = userManagement.getJwksUrl('client_123'); - expect(url).toBe('https://api.workos.com/sso/jwks/client_123'); - }); - }); - - describe('sso exports', () => { - it('should generate SSO authorization URLs correctly', () => { - const url = sso.getAuthorizationUrl({ - clientId: 'client_123', - redirectUri: 'https://example.com/callback', - provider: 'GoogleOAuth', - }); - - expect(url).toContain('client_id=client_123'); - expect(url).toContain('provider=GoogleOAuth'); - }); - }); -}); diff --git a/src/index.client.ts b/src/index.client.ts deleted file mode 100644 index 1ad430772..000000000 --- a/src/index.client.ts +++ /dev/null @@ -1,19 +0,0 @@ -/** - * Client methods that can be used without a WorkOS API key. - * These are OAuth client operations and URL builders suitable for PKCE flows. - */ - -// Export client methods directly - this is the recommended approach -export * as userManagement from './client/user-management'; -export * as sso from './client/sso'; - -// Re-export types for convenience -export type { - AuthorizationURLOptions as UserManagementAuthorizationURLOptions, - LogoutURLOptions, -} from './client/user-management'; - -export type { SSOAuthorizationURLOptions } from './client/sso'; - -// Note: If you need authenticateWithCodeAndVerifier, use the full WorkOS SDK -// as it requires server-side API key authentication diff --git a/src/index.ts b/src/index.ts index cea4eb124..de8e6d4a5 100644 --- a/src/index.ts +++ b/src/index.ts @@ -26,18 +26,34 @@ export * from './roles/interfaces'; export * from './sso/interfaces'; export * from './user-management/interfaces'; export * from './vault/interfaces'; +export * from './pkce/pkce'; class WorkOSNode extends WorkOS { /** @override */ createHttpClient(options: WorkOSOptions, userAgent: string): HttpClient { + const headers: Record = {}; + + const configHeaders = options.config?.headers; + if (configHeaders) { + if (configHeaders instanceof Headers) { + configHeaders.forEach((v, k) => (headers[k] = v)); + } else if (Array.isArray(configHeaders)) { + configHeaders.forEach(([k, v]) => (headers[k] = v)); + } else { + Object.assign(headers, configHeaders); + } + } + + headers['User-Agent'] = userAgent; + + if (this.key) { + headers['Authorization'] = `Bearer ${this.key}`; + } + const opts = { ...options.config, - timeout: options.timeout, // Pass through the timeout option - headers: { - ...options.config?.headers, - Authorization: `Bearer ${this.key}`, - 'User-Agent': userAgent, - }, + timeout: options.timeout, + headers, }; return new FetchHttpClient(this.baseURL, opts, options.fetchFn); diff --git a/src/index.worker.ts b/src/index.worker.ts index d93b1b852..71aa50571 100644 --- a/src/index.worker.ts +++ b/src/index.worker.ts @@ -22,17 +22,32 @@ export * from './portal/interfaces'; export * from './sso/interfaces'; export * from './user-management/interfaces'; export * from './roles/interfaces'; +export * from './pkce/pkce'; class WorkOSWorker extends WorkOS { /** @override */ createHttpClient(options: WorkOSOptions, userAgent: string): HttpClient { + const headers: Record = { + 'User-Agent': userAgent, + }; + + const configHeaders = options.config?.headers; + if ( + configHeaders && + typeof configHeaders === 'object' && + !Array.isArray(configHeaders) && + !(configHeaders instanceof Headers) + ) { + Object.assign(headers, configHeaders); + } + + if (this.key) { + headers['Authorization'] = `Bearer ${this.key}`; + } + return new FetchHttpClient(this.baseURL, { ...options.config, - headers: { - ...options.config?.headers, - Authorization: `Bearer ${this.key}`, - 'User-Agent': userAgent, - }, + headers, }); } diff --git a/src/pkce/pkce.spec.ts b/src/pkce/pkce.spec.ts new file mode 100644 index 000000000..2b0531e15 --- /dev/null +++ b/src/pkce/pkce.spec.ts @@ -0,0 +1,157 @@ +import { PKCE, PKCEPair } from './pkce'; + +describe('PKCE', () => { + let pkce: PKCE; + + beforeEach(() => { + pkce = new PKCE(); + }); + + describe('generateCodeVerifier', () => { + it('generates a string of default length (43)', () => { + const verifier = pkce.generateCodeVerifier(); + expect(verifier).toHaveLength(43); + }); + + it('generates a string of custom length', () => { + const verifier = pkce.generateCodeVerifier(128); + expect(verifier).toHaveLength(128); + }); + + it('generates only RFC 7636 compliant characters', () => { + const verifier = pkce.generateCodeVerifier(); + // RFC 7636: unreserved characters are [A-Z] / [a-z] / [0-9] / "-" / "." / "_" / "~" + expect(verifier).toMatch(/^[A-Za-z0-9\-._~]+$/); + }); + + it('generates unique values', () => { + const verifiers = new Set( + Array.from({ length: 100 }, () => pkce.generateCodeVerifier()), + ); + // All 100 should be unique + expect(verifiers.size).toBe(100); + }); + + it('throws RangeError for length < 43', () => { + expect(() => pkce.generateCodeVerifier(42)).toThrow(RangeError); + expect(() => pkce.generateCodeVerifier(42)).toThrow( + 'Code verifier length must be between 43 and 128', + ); + }); + + it('throws RangeError for length > 128', () => { + expect(() => pkce.generateCodeVerifier(129)).toThrow(RangeError); + expect(() => pkce.generateCodeVerifier(129)).toThrow( + 'Code verifier length must be between 43 and 128', + ); + }); + + it('accepts minimum length (43)', () => { + const verifier = pkce.generateCodeVerifier(43); + expect(verifier).toHaveLength(43); + }); + + it('accepts maximum length (128)', () => { + const verifier = pkce.generateCodeVerifier(128); + expect(verifier).toHaveLength(128); + }); + }); + + describe('generateCodeChallenge', () => { + it('generates base64url-encoded SHA-256 hash', async () => { + const verifier = 'test_verifier_with_exactly_43_characters_xx'; + const challenge = await pkce.generateCodeChallenge(verifier); + + // Challenge should be base64url encoded (no +, /, or = padding) + expect(challenge).toMatch(/^[A-Za-z0-9\-_]+$/); + }); + + it('produces consistent output for same input', async () => { + const verifier = 'consistent_test_verifier_exactly_43_chars_'; + const challenge1 = await pkce.generateCodeChallenge(verifier); + const challenge2 = await pkce.generateCodeChallenge(verifier); + + expect(challenge1).toBe(challenge2); + }); + + it('produces different output for different input', async () => { + const verifier1 = 'first_test_verifier_exactly_43_characters_'; + const verifier2 = 'second_test_verifier_exactly_43_character_'; + + const challenge1 = await pkce.generateCodeChallenge(verifier1); + const challenge2 = await pkce.generateCodeChallenge(verifier2); + + expect(challenge1).not.toBe(challenge2); + }); + + it('produces correct SHA-256 hash for known input', async () => { + // Using a well-known test vector + // The verifier "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk" should produce + // the challenge "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM" (from RFC 7636 Appendix B) + const verifier = 'dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk'; + const expectedChallenge = 'E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM'; + + const challenge = await pkce.generateCodeChallenge(verifier); + expect(challenge).toBe(expectedChallenge); + }); + }); + + describe('generate', () => { + it('returns a PKCEPair with all required fields', async () => { + const pair = await pkce.generate(); + + expect(pair).toHaveProperty('codeVerifier'); + expect(pair).toHaveProperty('codeChallenge'); + expect(pair).toHaveProperty('codeChallengeMethod'); + }); + + it('returns S256 as the challenge method', async () => { + const pair = await pkce.generate(); + expect(pair.codeChallengeMethod).toBe('S256'); + }); + + it('generates valid verifier', async () => { + const pair = await pkce.generate(); + + expect(pair.codeVerifier).toHaveLength(43); + expect(pair.codeVerifier).toMatch(/^[A-Za-z0-9\-._~]+$/); + }); + + it('generates matching challenge for verifier', async () => { + const pair = await pkce.generate(); + + // Verify the challenge matches what we'd generate from the verifier + const expectedChallenge = await pkce.generateCodeChallenge( + pair.codeVerifier, + ); + expect(pair.codeChallenge).toBe(expectedChallenge); + }); + + it('generates unique pairs', async () => { + const pairs = await Promise.all( + Array.from({ length: 10 }, () => pkce.generate()), + ); + + const verifiers = new Set(pairs.map((p) => p.codeVerifier)); + const challenges = new Set(pairs.map((p) => p.codeChallenge)); + + expect(verifiers.size).toBe(10); + expect(challenges.size).toBe(10); + }); + }); + + describe('PKCEPair type', () => { + it('has correct shape', async () => { + const pair: PKCEPair = await pkce.generate(); + + // TypeScript compilation would fail if these types are wrong + const _verifier: string = pair.codeVerifier; + const _challenge: string = pair.codeChallenge; + const _method: 'S256' = pair.codeChallengeMethod; + + expect(_verifier).toBeDefined(); + expect(_challenge).toBeDefined(); + expect(_method).toBe('S256'); + }); + }); +}); diff --git a/src/pkce/pkce.ts b/src/pkce/pkce.ts new file mode 100644 index 000000000..f97e67025 --- /dev/null +++ b/src/pkce/pkce.ts @@ -0,0 +1,62 @@ +export interface PKCEPair { + codeVerifier: string; + codeChallenge: string; + codeChallengeMethod: 'S256'; +} + +/** + * PKCE (Proof Key for Code Exchange) utilities for OAuth 2.0 public clients. + * + * Implements RFC 7636 for secure authorization code exchange without a client secret. + * Used by Electron apps, React Native/mobile apps, CLI tools, and other public clients. + */ +export class PKCE { + /** + * Generate a cryptographically random code verifier. + * + * @param length - Length of verifier (43-128, default 43) + * @returns RFC 7636 compliant code verifier + */ + generateCodeVerifier(length: number = 43): string { + if (length < 43 || length > 128) { + throw new RangeError( + `Code verifier length must be between 43 and 128, got ${length}`, + ); + } + + const byteLength = Math.ceil((length * 3) / 4); + const randomBytes = new Uint8Array(byteLength); + crypto.getRandomValues(randomBytes); + + return this.base64UrlEncode(randomBytes).slice(0, length); + } + + /** + * Generate S256 code challenge from a verifier. + * + * @param verifier - The code verifier + * @returns Base64URL-encoded SHA256 hash + */ + async generateCodeChallenge(verifier: string): Promise { + const encoder = new TextEncoder(); + const data = encoder.encode(verifier); + const hash = await crypto.subtle.digest('SHA-256', data); + return this.base64UrlEncode(new Uint8Array(hash)); + } + + /** + * Generate a complete PKCE pair (verifier + challenge). + * + * @returns Code verifier, challenge, and method ('S256') + */ + async generate(): Promise { + const codeVerifier = this.generateCodeVerifier(); + const codeChallenge = await this.generateCodeChallenge(codeVerifier); + return { codeVerifier, codeChallenge, codeChallengeMethod: 'S256' }; + } + + private base64UrlEncode(buffer: Uint8Array): string { + const base64 = btoa(String.fromCharCode(...buffer)); + return base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); + } +} diff --git a/src/sso/__snapshots__/sso.spec.ts.snap b/src/sso/__snapshots__/sso.spec.ts.snap index 1afb674c4..5941b55a8 100644 --- a/src/sso/__snapshots__/sso.spec.ts.snap +++ b/src/sso/__snapshots__/sso.spec.ts.snap @@ -16,7 +16,7 @@ exports[`SSO SSO getAuthorizationUrl with providerScopes generates an authorize exports[`SSO SSO getAuthorizationUrl with state generates an authorize url with the provided state 1`] = `"https://api.workos.com/sso/authorize?client_id=proj_123&provider=GoogleOAuth&redirect_uri=example.com%2Fsso%2Fworkos%2Fcallback&response_type=code&state=custom+state"`; -exports[`SSO SSO getProfileAndToken with all information provided sends a request to the WorkOS api for a profile 1`] = `"client_id=proj_123&client_secret=sk_test_Sz3IQjepeSWaI4cMS4ms4sMuU&grant_type=authorization_code&code=authorization_code"`; +exports[`SSO SSO getProfileAndToken with all information provided sends a request to the WorkOS api for a profile 1`] = `"client_id=proj_123&grant_type=authorization_code&code=authorization_code&client_secret=sk_test_Sz3IQjepeSWaI4cMS4ms4sMuU"`; exports[`SSO SSO getProfileAndToken with all information provided sends a request to the WorkOS api for a profile 2`] = ` { @@ -56,7 +56,7 @@ exports[`SSO SSO getProfileAndToken with all information provided sends a reques } `; -exports[`SSO SSO getProfileAndToken without a groups attribute sends a request to the WorkOS api for a profile 1`] = `"client_id=proj_123&client_secret=sk_test_Sz3IQjepeSWaI4cMS4ms4sMuU&grant_type=authorization_code&code=authorization_code"`; +exports[`SSO SSO getProfileAndToken without a groups attribute sends a request to the WorkOS api for a profile 1`] = `"client_id=proj_123&grant_type=authorization_code&code=authorization_code&client_secret=sk_test_Sz3IQjepeSWaI4cMS4ms4sMuU"`; exports[`SSO SSO getProfileAndToken without a groups attribute sends a request to the WorkOS api for a profile 2`] = ` { diff --git a/src/sso/interfaces/authorization-url-options.interface.ts b/src/sso/interfaces/authorization-url-options.interface.ts index 440824c79..a364da322 100644 --- a/src/sso/interfaces/authorization-url-options.interface.ts +++ b/src/sso/interfaces/authorization-url-options.interface.ts @@ -1,4 +1,12 @@ -interface SSOAuthorizationURLBase { +/** + * PKCE fields must be provided together or not at all. + * Use workos.pkce.generate() to create a valid pair. + */ +type PKCEFields = + | { codeChallenge?: never; codeChallengeMethod?: never } + | { codeChallenge: string; codeChallengeMethod: 'S256' }; + +interface SSOAuthorizationURLBaseFields { clientId: string; domainHint?: string; loginHint?: string; @@ -8,23 +16,42 @@ interface SSOAuthorizationURLBase { state?: string; } -interface SSOWithConnection extends SSOAuthorizationURLBase { - connection: string; - organization?: never; - provider?: never; +/** + * Result of getAuthorizationUrlWithPKCE() containing the URL, + * state, and PKCE code verifier. + * + * The codeVerifier must be stored securely and passed to + * getProfileAndToken() during token exchange. + */ +export interface SSOPKCEAuthorizationURLResult { + /** The complete authorization URL to redirect the user to */ + url: string; + /** The state parameter (auto-generated) */ + state: string; + /** The PKCE code verifier. Store securely and pass to getProfileAndToken(). */ + codeVerifier: string; } -interface SSOWithOrganization extends SSOAuthorizationURLBase { - organization: string; - connection?: never; - provider?: never; -} +type SSOWithConnection = SSOAuthorizationURLBaseFields & + PKCEFields & { + connection: string; + organization?: never; + provider?: never; + }; -interface SSOWithProvider extends SSOAuthorizationURLBase { - provider: string; - connection?: never; - organization?: never; -} +type SSOWithOrganization = SSOAuthorizationURLBaseFields & + PKCEFields & { + organization: string; + connection?: never; + provider?: never; + }; + +type SSOWithProvider = SSOAuthorizationURLBaseFields & + PKCEFields & { + provider: string; + connection?: never; + organization?: never; + }; export type SSOAuthorizationURLOptions = | SSOWithConnection diff --git a/src/sso/interfaces/get-profile-and-token-options.interface.ts b/src/sso/interfaces/get-profile-and-token-options.interface.ts index 59faaa4ee..6caeae0e5 100644 --- a/src/sso/interfaces/get-profile-and-token-options.interface.ts +++ b/src/sso/interfaces/get-profile-and-token-options.interface.ts @@ -1,4 +1,10 @@ export interface GetProfileAndTokenOptions { clientId: string; code: string; + /** + * PKCE code verifier for public clients. + * Pass the codeVerifier that was generated with getAuthorizationUrlWithPKCE(). + * When provided, client_secret is not sent (public client mode). + */ + codeVerifier?: string; } diff --git a/src/sso/interfaces/index.ts b/src/sso/interfaces/index.ts index 50e6fd889..6de6bc482 100644 --- a/src/sso/interfaces/index.ts +++ b/src/sso/interfaces/index.ts @@ -6,3 +6,4 @@ export * from './get-profile-and-token-options.interface'; export * from './list-connections-options.interface'; export * from './profile-and-token.interface'; export * from './profile.interface'; +export type { SSOPKCEAuthorizationURLResult } from './authorization-url-options.interface'; diff --git a/src/sso/sso.spec.ts b/src/sso/sso.spec.ts index 4313e09b3..cc7b3b4a0 100644 --- a/src/sso/sso.spec.ts +++ b/src/sso/sso.spec.ts @@ -260,6 +260,72 @@ describe('SSO', () => { ); }); }); + + describe('with PKCE parameters', () => { + it('includes codeChallenge and codeChallengeMethod in URL', () => { + const workos = new WorkOS('sk_test_Sz3IQjepeSWaI4cMS4ms4sMuU'); + + const url = workos.sso.getAuthorizationUrl({ + connection: 'conn_123', + clientId: 'proj_123', + redirectUri: 'myapp://callback', + codeChallenge: 'test-challenge', + codeChallengeMethod: 'S256', + }); + + expect(url).toContain('code_challenge=test-challenge'); + expect(url).toContain('code_challenge_method=S256'); + }); + }); + }); + + describe('getAuthorizationUrlWithPKCE', () => { + it('generates PKCE parameters and returns codeVerifier', async () => { + const workos = new WorkOS('sk_test_Sz3IQjepeSWaI4cMS4ms4sMuU'); + + const result = await workos.sso.getAuthorizationUrlWithPKCE({ + connection: 'conn_123', + clientId: 'proj_123', + redirectUri: 'myapp://callback', + }); + + expect(result.codeVerifier).toBeDefined(); + expect(result.codeVerifier.length).toBeGreaterThanOrEqual(43); + expect(result.url).toContain('code_challenge='); + expect(result.url).toContain('code_challenge_method=S256'); + expect(result.state).toBeDefined(); + expect(result.state.length).toBeGreaterThanOrEqual(43); + }); + + it('includes all provided options in the URL', async () => { + const workos = new WorkOS('sk_test_Sz3IQjepeSWaI4cMS4ms4sMuU'); + + const result = await workos.sso.getAuthorizationUrlWithPKCE({ + connection: 'conn_123', + clientId: 'proj_123', + redirectUri: 'myapp://callback', + domainHint: 'example.com', + loginHint: 'user@example.com', + }); + + expect(result.url).toContain('connection=conn_123'); + expect(result.url).toContain('client_id=proj_123'); + expect(result.url).toContain('domain_hint=example.com'); + expect(result.url).toContain('login_hint=user%40example.com'); + }); + + it('throws error when no connection, organization, or provider is specified', async () => { + const workos = new WorkOS('sk_test_Sz3IQjepeSWaI4cMS4ms4sMuU'); + + await expect( + workos.sso.getAuthorizationUrlWithPKCE({ + clientId: 'proj_123', + redirectUri: 'myapp://callback', + } as Parameters[0]), + ).rejects.toThrow( + `Incomplete arguments. Need to specify either a 'connection', 'organization', or 'provider'.`, + ); + }); }); describe('getProfileAndToken', () => { @@ -466,6 +532,125 @@ describe('SSO', () => { expect(oauthTokens).toBeUndefined(); }); }); + + describe('confidential client with PKCE (API key + codeVerifier)', () => { + it('sends both client_secret and code_verifier for defense in depth', async () => { + fetchOnce({ + access_token: '01DMEK0J53CVMC32CK5SE0KZ8Q', + profile: { + id: 'prof_123', + idp_id: '123', + organization_id: 'org_123', + connection_id: 'conn_123', + connection_type: 'OktaSAML', + email: 'foo@test.com', + first_name: 'foo', + last_name: 'bar', + role: { slug: 'admin' }, + roles: [{ slug: 'admin' }], + raw_attributes: {}, + custom_attributes: {}, + }, + }); + + const workos = new WorkOS('sk_test_Sz3IQjepeSWaI4cMS4ms4sMuU'); + await workos.sso.getProfileAndToken({ + code: 'authorization_code', + clientId: 'proj_123', + codeVerifier: 'test_code_verifier_value', + }); + + const body = fetchBody(); + expect(body).toContain('code_verifier=test_code_verifier_value'); + expect(body).toContain( + 'client_secret=sk_test_Sz3IQjepeSWaI4cMS4ms4sMuU', + ); + }); + }); + + describe('public client mode (codeVerifier without API key)', () => { + let publicWorkos: WorkOS; + let originalApiKey: string | undefined; + + beforeEach(() => { + originalApiKey = process.env.WORKOS_API_KEY; + delete process.env.WORKOS_API_KEY; + publicWorkos = new WorkOS({ clientId: 'proj_123' }); + }); + + afterEach(() => { + process.env.WORKOS_API_KEY = originalApiKey; + }); + + it('sends code_verifier without client_secret', async () => { + fetchOnce({ + access_token: '01DMEK0J53CVMC32CK5SE0KZ8Q', + profile: { + id: 'prof_123', + idp_id: '123', + organization_id: 'org_123', + connection_id: 'conn_123', + connection_type: 'OktaSAML', + email: 'foo@test.com', + first_name: 'foo', + last_name: 'bar', + role: { slug: 'admin' }, + roles: [{ slug: 'admin' }], + raw_attributes: {}, + custom_attributes: {}, + }, + }); + + const { accessToken } = await publicWorkos.sso.getProfileAndToken({ + code: 'authorization_code', + clientId: 'proj_123', + codeVerifier: 'test_code_verifier_value', + }); + + expect(accessToken).toBe('01DMEK0J53CVMC32CK5SE0KZ8Q'); + const body = fetchBody(); + expect(body).toContain('code_verifier=test_code_verifier_value'); + expect(body).not.toContain('client_secret'); + }); + + it('throws error when neither codeVerifier nor API key is provided', async () => { + await expect( + publicWorkos.sso.getProfileAndToken({ + code: 'authorization_code', + clientId: 'proj_123', + }), + ).rejects.toThrow( + 'getProfileAndToken requires either a codeVerifier (for public clients) ' + + 'or an API key configured on the WorkOS instance (for confidential clients).', + ); + }); + + it('throws error when codeVerifier is an empty string', async () => { + await expect( + publicWorkos.sso.getProfileAndToken({ + code: 'authorization_code', + clientId: 'proj_123', + codeVerifier: '', + }), + ).rejects.toThrow( + 'codeVerifier cannot be an empty string. ' + + 'Generate a valid PKCE pair using workos.pkce.generate().', + ); + }); + + it('throws error when codeVerifier is whitespace only', async () => { + await expect( + publicWorkos.sso.getProfileAndToken({ + code: 'authorization_code', + clientId: 'proj_123', + codeVerifier: ' ', + }), + ).rejects.toThrow( + 'codeVerifier cannot be an empty string. ' + + 'Generate a valid PKCE pair using workos.pkce.generate().', + ); + }); + }); }); describe('getProfile', () => { diff --git a/src/sso/sso.ts b/src/sso/sso.ts index a1b4633ae..41925b12d 100644 --- a/src/sso/sso.ts +++ b/src/sso/sso.ts @@ -1,7 +1,7 @@ -import * as clientSSO from '../client/sso'; import { UnknownRecord } from '../common/interfaces/unknown-record.interface'; import { fetchAndDeserialize } from '../common/utils/fetch-and-deserialize'; import { AutoPaginatable } from '../common/utils/pagination'; +import { toQueryString } from '../common/utils/query-string'; import { WorkOS } from '../workos'; import { Connection, @@ -14,6 +14,7 @@ import { ProfileAndTokenResponse, ProfileResponse, SSOAuthorizationURLOptions, + SSOPKCEAuthorizationURLResult, SerializedListConnectionsOptions, } from './interfaces'; import { @@ -51,11 +52,119 @@ export class SSO { } getAuthorizationUrl(options: SSOAuthorizationURLOptions): string { - // Delegate to client implementation - return clientSSO.getAuthorizationUrl({ - ...options, - baseURL: this.workos.baseURL, + const { + codeChallenge, + codeChallengeMethod, + connection, + clientId, + domainHint, + loginHint, + organization, + provider, + providerQueryParams, + providerScopes, + redirectUri, + state, + } = options; + + if (!provider && !connection && !organization) { + throw new TypeError( + `Incomplete arguments. Need to specify either a 'connection', 'organization', or 'provider'.`, + ); + } + + const query = toQueryString({ + code_challenge: codeChallenge, + code_challenge_method: codeChallengeMethod, + connection, + organization, + domain_hint: domainHint, + login_hint: loginHint, + provider, + provider_query_params: providerQueryParams, + provider_scopes: providerScopes, + client_id: clientId, + redirect_uri: redirectUri, + response_type: 'code', + state, + }); + + return `${this.workos.baseURL}/sso/authorize?${query}`; + } + + /** + * Generates an authorization URL with PKCE parameters automatically generated. + * Use this for public clients (CLI apps, Electron, mobile) that cannot + * securely store a client secret. + * + * @returns Object containing url, state, and codeVerifier + * + * @example + * ```typescript + * const { url, state, codeVerifier } = await workos.sso.getAuthorizationUrlWithPKCE({ + * connection: 'conn_123', + * clientId: 'client_123', + * redirectUri: 'myapp://callback', + * }); + * + * // Store state and codeVerifier securely, then redirect user to url + * // After callback, exchange the code: + * const { profile, accessToken } = await workos.sso.getProfileAndToken({ + * code: authorizationCode, + * codeVerifier, + * clientId: 'client_123', + * }); + * ``` + */ + async getAuthorizationUrlWithPKCE( + options: Omit< + SSOAuthorizationURLOptions, + 'codeChallenge' | 'codeChallengeMethod' | 'state' + >, + ): Promise { + const { + connection, + clientId, + domainHint, + loginHint, + organization, + provider, + providerQueryParams, + providerScopes, + redirectUri, + } = options; + + if (!provider && !connection && !organization) { + throw new TypeError( + `Incomplete arguments. Need to specify either a 'connection', 'organization', or 'provider'.`, + ); + } + + // Generate PKCE parameters + const pkce = await this.workos.pkce.generate(); + + // Generate secure random state + const state = this.workos.pkce.generateCodeVerifier(43); + + const query = toQueryString({ + code_challenge: pkce.codeChallenge, + code_challenge_method: 'S256', + connection, + organization, + domain_hint: domainHint, + login_hint: loginHint, + provider, + provider_query_params: providerQueryParams, + provider_scopes: providerScopes, + client_id: clientId, + redirect_uri: redirectUri, + response_type: 'code', + state, }); + + const url = `${this.workos.baseURL}/sso/authorize?${query}`; + + return { url, state, codeVerifier: pkce.codeVerifier }; } async getConnection(id: string): Promise { @@ -66,24 +175,64 @@ export class SSO { return deserializeConnection(data); } + /** + * Exchange an authorization code for a profile and access token. + * + * Auto-detects public vs confidential client mode: + * - If codeVerifier is provided: Uses PKCE flow (public client) + * - If no codeVerifier: Uses client_secret from API key (confidential client) + * - If both: Uses both client_secret AND codeVerifier (confidential client with PKCE) + * + * Using PKCE with confidential clients is recommended by OAuth 2.1 for defense + * in depth and provides additional CSRF protection on the authorization flow. + * + * @throws Error if neither codeVerifier nor API key is available + */ async getProfileAndToken< CustomAttributesType extends UnknownRecord = UnknownRecord, >({ code, clientId, + codeVerifier, }: GetProfileAndTokenOptions): Promise< ProfileAndToken > { + // Validate codeVerifier is not an empty string (common mistake) + if (codeVerifier !== undefined && codeVerifier.trim() === '') { + throw new TypeError( + 'codeVerifier cannot be an empty string. ' + + 'Generate a valid PKCE pair using workos.pkce.generate().', + ); + } + + const hasApiKey = !!this.workos.key; + const hasPKCE = !!codeVerifier; + + if (!hasPKCE && !hasApiKey) { + throw new TypeError( + 'getProfileAndToken requires either a codeVerifier (for public clients) ' + + 'or an API key configured on the WorkOS instance (for confidential clients).', + ); + } + const form = new URLSearchParams({ client_id: clientId, - client_secret: this.workos.key as string, grant_type: 'authorization_code', code, }); + // Support PKCE with confidential clients (OAuth 2.1 best practice) + // Both can be sent together for defense in depth + if (hasPKCE) { + form.set('code_verifier', codeVerifier); + } + if (hasApiKey) { + form.set('client_secret', this.workos.key as string); + } + const { data } = await this.workos.post< ProfileAndTokenResponse - >('/sso/token', form); + >('/sso/token', form, { skipApiKeyCheck: !hasApiKey }); return deserializeProfileAndToken(data); } diff --git a/src/user-management/__snapshots__/user-management.spec.ts.snap b/src/user-management/__snapshots__/user-management.spec.ts.snap deleted file mode 100644 index eac0c2247..000000000 --- a/src/user-management/__snapshots__/user-management.spec.ts.snap +++ /dev/null @@ -1,25 +0,0 @@ -// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing - -exports[`UserManagement getAuthorizationUrl with a code_challenge and code_challenge_method generates an authorize url 1`] = `"https://api.workos.com/user_management/authorize?client_id=proj_123&code_challenge=code_challenge_value&code_challenge_method=S256&provider=authkit&redirect_uri=example.com%2Fauth%2Fworkos%2Fcallback&response_type=code"`; - -exports[`UserManagement getAuthorizationUrl with a connectionId generates an authorize url with the connection 1`] = `"https://api.workos.com/user_management/authorize?client_id=proj_123&connection_id=connection_123&redirect_uri=example.com%2Fauth%2Fworkos%2Fcallback&response_type=code"`; - -exports[`UserManagement getAuthorizationUrl with a connectionId with providerScopes generates an authorize url that includes the specified scopes 1`] = `"https://api.workos.com/user_management/authorize?client_id=proj_123&connection_id=connection_123&provider_scopes=read_api&provider_scopes=read_repository&redirect_uri=example.com%2Fauth%2Fworkos%2Fcallback&response_type=code"`; - -exports[`UserManagement getAuthorizationUrl with a custom api hostname generates an authorize url with the custom api hostname 1`] = `"https://api.workos.dev/user_management/authorize?client_id=proj_123&organization_id=organization_123&redirect_uri=example.com%2Fauth%2Fworkos%2Fcallback&response_type=code"`; - -exports[`UserManagement getAuthorizationUrl with a provider generates an authorize url with the provider 1`] = `"https://api.workos.com/user_management/authorize?client_id=proj_123&provider=GoogleOAuth&redirect_uri=example.com%2Fauth%2Fworkos%2Fcallback&response_type=code"`; - -exports[`UserManagement getAuthorizationUrl with a provider with providerScopes generates an authorize url that includes the specified scopes 1`] = `"https://api.workos.com/user_management/authorize?client_id=proj_123&provider=GoogleOAuth&provider_scopes=https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fcalendar&provider_scopes=https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fadmin.directory.group&redirect_uri=example.com%2Fauth%2Fworkos%2Fcallback&response_type=code"`; - -exports[`UserManagement getAuthorizationUrl with a provider with providerScopes with providerQueryParams generates an authorize url that includes the specified query params 1`] = `"https://api.workos.com/user_management/authorize?client_id=proj_123&provider=GoogleOAuth&provider_query_params%5Bbaz%5D=123&provider_query_params%5Bbool%5D=true&provider_query_params%5Bfoo%5D=bar&redirect_uri=example.com%2Fauth%2Fworkos%2Fcallback&response_type=code"`; - -exports[`UserManagement getAuthorizationUrl with a screenHint generates an authorize url with a screenHint 1`] = `"https://api.workos.com/user_management/authorize?client_id=proj_123&provider=authkit&redirect_uri=example.com%2Fauth%2Fworkos%2Fcallback&response_type=code&screen_hint=sign-up"`; - -exports[`UserManagement getAuthorizationUrl with an organizationId generates an authorization URL with the organization 1`] = `"https://api.workos.com/user_management/authorize?client_id=proj_123&organization_id=organization_123&redirect_uri=example.com%2Fauth%2Fworkos%2Fcallback&response_type=code"`; - -exports[`UserManagement getAuthorizationUrl with no custom api hostname generates an authorize url with the default api hostname 1`] = `"https://api.workos.com/user_management/authorize?client_id=proj_123&provider=GoogleOAuth&redirect_uri=example.com%2Fauth%2Fworkos%2Fcallback&response_type=code"`; - -exports[`UserManagement getAuthorizationUrl with no domain or provider throws an error for incomplete arguments 1`] = `"Incomplete arguments. Need to specify either a 'connectionId', 'organizationId', or 'provider'."`; - -exports[`UserManagement getAuthorizationUrl with state generates an authorize url with the provided state 1`] = `"https://api.workos.com/user_management/authorize?client_id=proj_123&organization_id=organization_123&redirect_uri=example.com%2Fauth%2Fworkos%2Fcallback&response_type=code&state=custom+state"`; diff --git a/src/user-management/interfaces/authenticate-with-code-and-verifier-options.interface.ts b/src/user-management/interfaces/authenticate-with-code-and-verifier-options.interface.ts index 5a8085ac6..f55c4f4c5 100644 --- a/src/user-management/interfaces/authenticate-with-code-and-verifier-options.interface.ts +++ b/src/user-management/interfaces/authenticate-with-code-and-verifier-options.interface.ts @@ -1,6 +1,6 @@ import { AuthenticateWithOptionsBase, - SerializedAuthenticateWithPKCEBase, + SerializedAuthenticatePublicClientBase, } from './authenticate-with-options-base.interface'; export interface AuthenticateWithCodeAndVerifierOptions @@ -11,7 +11,7 @@ export interface AuthenticateWithCodeAndVerifierOptions } export interface SerializedAuthenticateWithCodeAndVerifierOptions - extends SerializedAuthenticateWithPKCEBase { + extends SerializedAuthenticatePublicClientBase { grant_type: 'authorization_code'; code_verifier: string; code: string; diff --git a/src/user-management/interfaces/authenticate-with-options-base.interface.ts b/src/user-management/interfaces/authenticate-with-options-base.interface.ts index 57fa869d3..f73a41fa4 100644 --- a/src/user-management/interfaces/authenticate-with-options-base.interface.ts +++ b/src/user-management/interfaces/authenticate-with-options-base.interface.ts @@ -4,7 +4,7 @@ export interface AuthenticateWithSessionOptions { } export interface AuthenticateWithOptionsBase { - clientId: string; + clientId?: string; ipAddress?: string; userAgent?: string; session?: AuthenticateWithSessionOptions; @@ -17,8 +17,25 @@ export interface SerializedAuthenticateWithOptionsBase { user_agent?: string; } -export interface SerializedAuthenticateWithPKCEBase { +/** Base for serialized auth options that don't require client_secret (public clients) */ +export interface SerializedAuthenticatePublicClientBase { client_id: string; ip_address?: string; user_agent?: string; } + +/** + * Utility type for serializer input signatures. + * + * Since `clientId` is optional in user-facing interfaces (allowing fallback to + * the constructor-provided value), but serializers require a resolved string, + * this type overrides the optional `clientId` with a required one. + * + * Usage in serializers: + * ``` + * const serialize = (options: WithResolvedClientId) => ... + * ``` + */ +export type WithResolvedClientId = Omit & { + clientId: string; +}; diff --git a/src/user-management/interfaces/authenticate-with-refresh-token-public-client-options.interface.ts b/src/user-management/interfaces/authenticate-with-refresh-token-public-client-options.interface.ts new file mode 100644 index 000000000..b872d32a8 --- /dev/null +++ b/src/user-management/interfaces/authenticate-with-refresh-token-public-client-options.interface.ts @@ -0,0 +1,18 @@ +import { + AuthenticateWithOptionsBase, + SerializedAuthenticatePublicClientBase, +} from './authenticate-with-options-base.interface'; + +/** Options for refreshing tokens as a public client (no client_secret required) */ +export interface AuthenticateWithRefreshTokenPublicClientOptions + extends AuthenticateWithOptionsBase { + refreshToken: string; + organizationId?: string; +} + +export interface SerializedAuthenticateWithRefreshTokenPublicClientOptions + extends SerializedAuthenticatePublicClientBase { + grant_type: 'refresh_token'; + refresh_token: string; + organization_id: string | undefined; +} diff --git a/src/user-management/interfaces/authorization-url-options.interface.ts b/src/user-management/interfaces/authorization-url-options.interface.ts index 34081c2b7..8ce5b8899 100644 --- a/src/user-management/interfaces/authorization-url-options.interface.ts +++ b/src/user-management/interfaces/authorization-url-options.interface.ts @@ -1,7 +1,13 @@ -export interface UserManagementAuthorizationURLOptions { - clientId: string; - codeChallenge?: string; - codeChallengeMethod?: 'S256'; +/** + * PKCE fields must be provided together or not at all. + * Use workos.pkce.generate() to create a valid pair. + */ +type PKCEFields = + | { codeChallenge?: never; codeChallengeMethod?: never } + | { codeChallenge: string; codeChallengeMethod: 'S256' }; + +interface UserManagementAuthorizationURLBaseOptions { + clientId?: string; connectionId?: string; organizationId?: string; domainHint?: string; @@ -14,3 +20,22 @@ export interface UserManagementAuthorizationURLOptions { state?: string; screenHint?: 'sign-up' | 'sign-in'; } + +export type UserManagementAuthorizationURLOptions = + UserManagementAuthorizationURLBaseOptions & PKCEFields; + +/** + * Result of getAuthorizationUrlWithPKCE() containing the URL, + * state, and PKCE code verifier. + * + * The codeVerifier must be stored securely and passed to + * authenticateWithCode() during token exchange. + */ +export interface PKCEAuthorizationURLResult { + /** The complete authorization URL to redirect the user to */ + url: string; + /** The state parameter (auto-generated) */ + state: string; + /** The PKCE code verifier. Store securely and pass to authenticateWithCode(). */ + codeVerifier: string; +} diff --git a/src/user-management/interfaces/index.ts b/src/user-management/interfaces/index.ts index f85f9a477..52fd897f5 100644 --- a/src/user-management/interfaces/index.ts +++ b/src/user-management/interfaces/index.ts @@ -6,6 +6,7 @@ export * from './authenticate-with-options-base.interface'; export * from './authenticate-with-organization-selection.interface'; export * from './authenticate-with-password-options.interface'; export * from './authenticate-with-refresh-token-options.interface'; +export * from './authenticate-with-refresh-token-public-client-options.interface'; export * from './authenticate-with-session-cookie.interface'; export * from './authenticate-with-totp-options.interface'; export * from './authentication-event.interface'; @@ -28,6 +29,7 @@ export * from './list-organization-memberships-options.interface'; export * from './list-sessions-options.interface'; export * from './list-user-feature-flags-options.interface'; export * from './list-users-options.interface'; +export * from './logout-url-options.interface'; export * from './magic-auth.interface'; export * from './oauth-tokens.interface'; export * from './organization-membership.interface'; diff --git a/src/user-management/interfaces/logout-url-options.interface.ts b/src/user-management/interfaces/logout-url-options.interface.ts new file mode 100644 index 000000000..2b90ac174 --- /dev/null +++ b/src/user-management/interfaces/logout-url-options.interface.ts @@ -0,0 +1,4 @@ +export interface LogoutURLOptions { + sessionId: string; + returnTo?: string; +} diff --git a/src/user-management/serializers/authenticate-with-code-and-verifier-options.serializer.ts b/src/user-management/serializers/authenticate-with-code-and-verifier-options.serializer.ts index ad64ea13b..a67c40a4a 100644 --- a/src/user-management/serializers/authenticate-with-code-and-verifier-options.serializer.ts +++ b/src/user-management/serializers/authenticate-with-code-and-verifier-options.serializer.ts @@ -1,10 +1,11 @@ import { AuthenticateWithCodeAndVerifierOptions, SerializedAuthenticateWithCodeAndVerifierOptions, + WithResolvedClientId, } from '../interfaces'; export const serializeAuthenticateWithCodeAndVerifierOptions = ( - options: AuthenticateWithCodeAndVerifierOptions, + options: WithResolvedClientId, ): SerializedAuthenticateWithCodeAndVerifierOptions => ({ grant_type: 'authorization_code', client_id: options.clientId, diff --git a/src/user-management/serializers/authenticate-with-code-options.serializer.ts b/src/user-management/serializers/authenticate-with-code-options.serializer.ts index c329c3aef..4f97e8302 100644 --- a/src/user-management/serializers/authenticate-with-code-options.serializer.ts +++ b/src/user-management/serializers/authenticate-with-code-options.serializer.ts @@ -2,10 +2,12 @@ import { AuthenticateUserWithCodeCredentials, AuthenticateWithCodeOptions, SerializedAuthenticateWithCodeOptions, + WithResolvedClientId, } from '../interfaces'; export const serializeAuthenticateWithCodeOptions = ( - options: AuthenticateWithCodeOptions & AuthenticateUserWithCodeCredentials, + options: WithResolvedClientId & + AuthenticateUserWithCodeCredentials, ): SerializedAuthenticateWithCodeOptions => ({ grant_type: 'authorization_code', client_id: options.clientId, diff --git a/src/user-management/serializers/authenticate-with-email-verification.serializer.ts b/src/user-management/serializers/authenticate-with-email-verification.serializer.ts index 2354d859f..043b98334 100644 --- a/src/user-management/serializers/authenticate-with-email-verification.serializer.ts +++ b/src/user-management/serializers/authenticate-with-email-verification.serializer.ts @@ -3,9 +3,10 @@ import { AuthenticateWithEmailVerificationOptions, SerializedAuthenticateWithEmailVerificationOptions, } from '../interfaces/authenticate-with-email-verification-options.interface'; +import { WithResolvedClientId } from '../interfaces'; export const serializeAuthenticateWithEmailVerificationOptions = ( - options: AuthenticateWithEmailVerificationOptions & + options: WithResolvedClientId & AuthenticateUserWithEmailVerificationCredentials, ): SerializedAuthenticateWithEmailVerificationOptions => ({ grant_type: 'urn:workos:oauth:grant-type:email-verification:code', diff --git a/src/user-management/serializers/authenticate-with-magic-auth-options.serializer.ts b/src/user-management/serializers/authenticate-with-magic-auth-options.serializer.ts index de5246a1a..ce7bff02c 100644 --- a/src/user-management/serializers/authenticate-with-magic-auth-options.serializer.ts +++ b/src/user-management/serializers/authenticate-with-magic-auth-options.serializer.ts @@ -2,10 +2,11 @@ import { AuthenticateUserWithMagicAuthCredentials, AuthenticateWithMagicAuthOptions, SerializedAuthenticateWithMagicAuthOptions, + WithResolvedClientId, } from '../interfaces'; export const serializeAuthenticateWithMagicAuthOptions = ( - options: AuthenticateWithMagicAuthOptions & + options: WithResolvedClientId & AuthenticateUserWithMagicAuthCredentials, ): SerializedAuthenticateWithMagicAuthOptions => ({ grant_type: 'urn:workos:oauth:grant-type:magic-auth:code', diff --git a/src/user-management/serializers/authenticate-with-organization-selection-options.serializer.ts b/src/user-management/serializers/authenticate-with-organization-selection-options.serializer.ts index 5c569363c..bf0a8c4df 100644 --- a/src/user-management/serializers/authenticate-with-organization-selection-options.serializer.ts +++ b/src/user-management/serializers/authenticate-with-organization-selection-options.serializer.ts @@ -3,9 +3,10 @@ import { AuthenticateWithOrganizationSelectionOptions, SerializedAuthenticateWithOrganizationSelectionOptions, } from '../interfaces/authenticate-with-organization-selection.interface'; +import { WithResolvedClientId } from '../interfaces'; export const serializeAuthenticateWithOrganizationSelectionOptions = ( - options: AuthenticateWithOrganizationSelectionOptions & + options: WithResolvedClientId & AuthenticateUserWithOrganizationSelectionCredentials, ): SerializedAuthenticateWithOrganizationSelectionOptions => ({ grant_type: 'urn:workos:oauth:grant-type:organization-selection', diff --git a/src/user-management/serializers/authenticate-with-password-options.serializer.ts b/src/user-management/serializers/authenticate-with-password-options.serializer.ts index b8b0f840d..405dadc5a 100644 --- a/src/user-management/serializers/authenticate-with-password-options.serializer.ts +++ b/src/user-management/serializers/authenticate-with-password-options.serializer.ts @@ -2,10 +2,11 @@ import { AuthenticateUserWithPasswordCredentials, AuthenticateWithPasswordOptions, SerializedAuthenticateWithPasswordOptions, + WithResolvedClientId, } from '../interfaces'; export const serializeAuthenticateWithPasswordOptions = ( - options: AuthenticateWithPasswordOptions & + options: WithResolvedClientId & AuthenticateUserWithPasswordCredentials, ): SerializedAuthenticateWithPasswordOptions => ({ grant_type: 'password', diff --git a/src/user-management/serializers/authenticate-with-refresh-token-public-client-options.serializer.ts b/src/user-management/serializers/authenticate-with-refresh-token-public-client-options.serializer.ts new file mode 100644 index 000000000..398318224 --- /dev/null +++ b/src/user-management/serializers/authenticate-with-refresh-token-public-client-options.serializer.ts @@ -0,0 +1,16 @@ +import { + AuthenticateWithRefreshTokenPublicClientOptions, + SerializedAuthenticateWithRefreshTokenPublicClientOptions, + WithResolvedClientId, +} from '../interfaces'; + +export const serializeAuthenticateWithRefreshTokenPublicClientOptions = ( + options: WithResolvedClientId, +): SerializedAuthenticateWithRefreshTokenPublicClientOptions => ({ + grant_type: 'refresh_token', + client_id: options.clientId, + refresh_token: options.refreshToken, + organization_id: options.organizationId, + ip_address: options.ipAddress, + user_agent: options.userAgent, +}); diff --git a/src/user-management/serializers/authenticate-with-refresh-token.options.serializer.ts b/src/user-management/serializers/authenticate-with-refresh-token.options.serializer.ts index ca819478a..0b558844c 100644 --- a/src/user-management/serializers/authenticate-with-refresh-token.options.serializer.ts +++ b/src/user-management/serializers/authenticate-with-refresh-token.options.serializer.ts @@ -2,10 +2,11 @@ import { AuthenticateUserWithCodeCredentials, AuthenticateWithRefreshTokenOptions, SerializedAuthenticateWithRefreshTokenOptions, + WithResolvedClientId, } from '../interfaces'; export const serializeAuthenticateWithRefreshTokenOptions = ( - options: AuthenticateWithRefreshTokenOptions & + options: WithResolvedClientId & AuthenticateUserWithCodeCredentials, ): SerializedAuthenticateWithRefreshTokenOptions => ({ grant_type: 'refresh_token', diff --git a/src/user-management/serializers/authenticate-with-totp-options.serializer.ts b/src/user-management/serializers/authenticate-with-totp-options.serializer.ts index 69b8f896b..d51ed7181 100644 --- a/src/user-management/serializers/authenticate-with-totp-options.serializer.ts +++ b/src/user-management/serializers/authenticate-with-totp-options.serializer.ts @@ -2,10 +2,12 @@ import { AuthenticateUserWithTotpCredentials, AuthenticateWithTotpOptions, SerializedAuthenticateWithTotpOptions, + WithResolvedClientId, } from '../interfaces'; export const serializeAuthenticateWithTotpOptions = ( - options: AuthenticateWithTotpOptions & AuthenticateUserWithTotpCredentials, + options: WithResolvedClientId & + AuthenticateUserWithTotpCredentials, ): SerializedAuthenticateWithTotpOptions => ({ grant_type: 'urn:workos:oauth:grant-type:mfa-totp', client_id: options.clientId, diff --git a/src/user-management/serializers/index.ts b/src/user-management/serializers/index.ts index 211d42b41..d4cc80ebf 100644 --- a/src/user-management/serializers/index.ts +++ b/src/user-management/serializers/index.ts @@ -3,6 +3,7 @@ export * from './authenticate-with-code-and-verifier-options.serializer'; export * from './authenticate-with-magic-auth-options.serializer'; export * from './authenticate-with-password-options.serializer'; export * from './authenticate-with-refresh-token.options.serializer'; +export * from './authenticate-with-refresh-token-public-client-options.serializer'; export * from './authenticate-with-totp-options.serializer'; export * from './authentication-event.serializer'; export * from './authentication-response.serializer'; diff --git a/src/user-management/session.spec.ts b/src/user-management/session.spec.ts index 37c77d7ce..d41ecec24 100644 --- a/src/user-management/session.spec.ts +++ b/src/user-management/session.spec.ts @@ -72,7 +72,10 @@ describe('Session', () => { it('returns a failed response if the accessToken is not a valid JWT', async () => { jest.mocked(jose.jwtVerify).mockImplementation(() => { - throw new Error('Invalid JWT'); + // Simulate a jose JWT validation error with the expected code property + const error = new Error('Invalid JWT'); + (error as Error & { code: string }).code = 'ERR_JWT_INVALID'; + throw error; }); const cookiePassword = 'alongcookiesecretmadefortestingsessions'; diff --git a/src/user-management/session.ts b/src/user-management/session.ts index 28467548c..bf8577e24 100644 --- a/src/user-management/session.ts +++ b/src/user-management/session.ts @@ -54,19 +54,11 @@ export class CookieSession { }; } - let session: SessionCookieData; - - try { - session = await unsealData(this.sessionData, { - password: this.cookiePassword, - }); - } catch (e) { - return { - authenticated: false, - reason: - AuthenticateWithSessionCookieFailureReason.INVALID_SESSION_COOKIE, - }; - } + // unsealData returns {} for known seal errors (expired, bad hmac, etc.) + // Unknown errors propagate - don't catch them as "invalid session" + const session = await unsealData(this.sessionData, { + password: this.cookiePassword, + }); if (!session.accessToken) { return { @@ -238,7 +230,17 @@ export class CookieSession { await jwtVerify(accessToken, jwks); return true; } catch (e) { - return false; + // Only treat as invalid JWT if it's an actual JWT/JWS error from jose + // Network errors, crypto failures, etc. should propagate + if ( + e instanceof Error && + 'code' in e && + typeof e.code === 'string' && + (e.code.startsWith('ERR_JWT_') || e.code.startsWith('ERR_JWS_')) + ) { + return false; + } + throw e; } } } diff --git a/src/user-management/user-management.spec.ts b/src/user-management/user-management.spec.ts index 67881e68a..675d554c3 100644 --- a/src/user-management/user-management.spec.ts +++ b/src/user-management/user-management.spec.ts @@ -306,49 +306,173 @@ describe('UserManagement', () => { }); describe('authenticateWithCode', () => { - it('sends a token authentication request', async () => { - fetchOnce({ user: userFixture }); - const resp = await workos.userManagement.authenticateWithCode({ - clientId: 'proj_whatever', - code: 'or this', + describe('confidential client mode (with API key, no codeVerifier)', () => { + it('sends a token authentication request with client_secret', async () => { + fetchOnce({ user: userFixture }); + const resp = await workos.userManagement.authenticateWithCode({ + clientId: 'proj_whatever', + code: 'or this', + }); + + expect(fetchURL()).toContain('/user_management/authenticate'); + expect(fetchBody()).toEqual({ + client_id: 'proj_whatever', + client_secret: 'sk_test_Sz3IQjepeSWaI4cMS4ms4sMuU', + code: 'or this', + grant_type: 'authorization_code', + }); + + expect(resp).toMatchObject({ + user: { + email: 'test01@example.com', + }, + }); }); + }); - expect(fetchURL()).toContain('/user_management/authenticate'); - expect(fetchBody()).toEqual({ - client_id: 'proj_whatever', - client_secret: 'sk_test_Sz3IQjepeSWaI4cMS4ms4sMuU', - code: 'or this', - grant_type: 'authorization_code', + describe('public client mode (with codeVerifier, no API key)', () => { + let publicWorkos: WorkOS; + let originalApiKey: string | undefined; + + beforeEach(() => { + originalApiKey = process.env.WORKOS_API_KEY; + delete process.env.WORKOS_API_KEY; + publicWorkos = new WorkOS({ clientId: 'proj_123' }); }); - expect(resp).toMatchObject({ - user: { - email: 'test01@example.com', - }, + afterEach(() => { + if (originalApiKey) { + process.env.WORKOS_API_KEY = originalApiKey; + } + }); + + it('sends a token authentication request with code_verifier and no client_secret', async () => { + fetchOnce({ user: userFixture }); + const resp = await publicWorkos.userManagement.authenticateWithCode({ + clientId: 'proj_whatever', + code: 'or this', + codeVerifier: 'code_verifier_value', + }); + + expect(fetchURL()).toContain('/user_management/authenticate'); + expect(fetchBody()).toEqual({ + client_id: 'proj_whatever', + code: 'or this', + code_verifier: 'code_verifier_value', + grant_type: 'authorization_code', + }); + + expect(resp).toMatchObject({ + user: { + email: 'test01@example.com', + }, + }); + }); + + it('uses clientId from constructor when not provided in options', async () => { + fetchOnce({ user: userFixture }); + await publicWorkos.userManagement.authenticateWithCode({ + code: 'or this', + codeVerifier: 'code_verifier_value', + }); + + expect(fetchBody()).toMatchObject({ + client_id: 'proj_123', + }); + }); + + it('throws error when clientId not provided anywhere', async () => { + // Use confidential client (API key) without clientId to test the error + const workosNoClientId = new WorkOS('sk_test_no_client_id'); + await expect( + workosNoClientId.userManagement.authenticateWithCode({ + code: 'some_code', + codeVerifier: 'code_verifier_value', + }), + ).rejects.toThrow( + 'clientId is required. Provide it in method options or when initializing WorkOS.', + ); }); }); - it('sends a token authentication request when including the code_verifier', async () => { - fetchOnce({ user: userFixture }); - const resp = await workos.userManagement.authenticateWithCode({ - clientId: 'proj_whatever', - code: 'or this', - codeVerifier: 'code_verifier_value', + describe('confidential client with PKCE (API key + codeVerifier)', () => { + it('sends both client_secret and code_verifier for defense in depth', async () => { + fetchOnce({ user: userFixture }); + const resp = await workos.userManagement.authenticateWithCode({ + clientId: 'proj_whatever', + code: 'or this', + codeVerifier: 'code_verifier_value', + }); + + expect(fetchURL()).toContain('/user_management/authenticate'); + expect(fetchBody()).toEqual({ + client_id: 'proj_whatever', + client_secret: 'sk_test_Sz3IQjepeSWaI4cMS4ms4sMuU', + code: 'or this', + code_verifier: 'code_verifier_value', + grant_type: 'authorization_code', + }); + + expect(resp).toMatchObject({ + user: { + email: 'test01@example.com', + }, + }); }); + }); - expect(fetchURL()).toContain('/user_management/authenticate'); - expect(fetchBody()).toEqual({ - client_id: 'proj_whatever', - client_secret: 'sk_test_Sz3IQjepeSWaI4cMS4ms4sMuU', - code: 'or this', - code_verifier: 'code_verifier_value', - grant_type: 'authorization_code', + describe('error handling', () => { + let publicWorkos: WorkOS; + let originalApiKey: string | undefined; + + beforeEach(() => { + originalApiKey = process.env.WORKOS_API_KEY; + delete process.env.WORKOS_API_KEY; + publicWorkos = new WorkOS({ clientId: 'proj_123' }); }); - expect(resp).toMatchObject({ - user: { - email: 'test01@example.com', - }, + afterEach(() => { + if (originalApiKey) { + process.env.WORKOS_API_KEY = originalApiKey; + } + }); + + it('throws error when neither codeVerifier nor API key is provided', async () => { + await expect( + publicWorkos.userManagement.authenticateWithCode({ + clientId: 'proj_whatever', + code: 'some_code', + }), + ).rejects.toThrow( + 'authenticateWithCode requires either a codeVerifier (for public clients) ' + + 'or an API key configured on the WorkOS instance (for confidential clients).', + ); + }); + + it('throws error when codeVerifier is an empty string', async () => { + await expect( + publicWorkos.userManagement.authenticateWithCode({ + clientId: 'proj_whatever', + code: 'some_code', + codeVerifier: '', + }), + ).rejects.toThrow( + 'codeVerifier cannot be an empty string. ' + + 'Generate a valid PKCE pair using workos.pkce.generate().', + ); + }); + + it('throws error when codeVerifier is whitespace only', async () => { + await expect( + publicWorkos.userManagement.authenticateWithCode({ + clientId: 'proj_whatever', + code: 'some_code', + codeVerifier: ' ', + }), + ).rejects.toThrow( + 'codeVerifier cannot be an empty string. ' + + 'Generate a valid PKCE pair using workos.pkce.generate().', + ); }); }); @@ -571,6 +695,41 @@ describe('UserManagement', () => { }); }); }); + + describe('in public client mode (no API key)', () => { + let publicWorkos: WorkOS; + let originalApiKey: string | undefined; + + beforeEach(() => { + originalApiKey = process.env.WORKOS_API_KEY; + delete process.env.WORKOS_API_KEY; + publicWorkos = new WorkOS({ clientId: 'client_123' }); + }); + + afterEach(() => { + if (originalApiKey) { + process.env.WORKOS_API_KEY = originalApiKey; + } + }); + + it('throws error when session sealing is requested', async () => { + fetchOnce({ + user: userFixture, + access_token: 'access_token', + }); + + await expect( + publicWorkos.userManagement.authenticateWithCodeAndVerifier({ + clientId: 'client_123', + code: 'auth_code_123', + codeVerifier: 'required_code_verifier', + session: { sealSession: true, cookiePassword: 'secret' }, + }), + ).rejects.toThrow( + 'Session sealing requires server-side usage with an API key', + ); + }); + }); }); }); @@ -646,6 +805,87 @@ describe('UserManagement', () => { }); }); }); + + describe('in public client mode (no API key)', () => { + let publicWorkos: WorkOS; + let originalApiKey: string | undefined; + + beforeEach(() => { + originalApiKey = process.env.WORKOS_API_KEY; + delete process.env.WORKOS_API_KEY; + publicWorkos = new WorkOS({ clientId: 'client_123' }); + }); + + afterEach(() => { + if (originalApiKey) { + process.env.WORKOS_API_KEY = originalApiKey; + } + }); + + it('omits client_secret from request', async () => { + fetchOnce({ + user: userFixture, + access_token: 'access_token', + refresh_token: 'refreshToken2', + }); + const resp = + await publicWorkos.userManagement.authenticateWithRefreshToken({ + clientId: 'client_123', + refreshToken: 'refresh_token1', + }); + + expect(fetchURL()).toContain('/user_management/authenticate'); + expect(fetchBody()).toEqual({ + client_id: 'client_123', + refresh_token: 'refresh_token1', + grant_type: 'refresh_token', + }); + expect(fetchBody()).not.toHaveProperty('client_secret'); + + expect(resp).toMatchObject({ + accessToken: 'access_token', + refreshToken: 'refreshToken2', + }); + }); + + it('includes organization_id when provided', async () => { + fetchOnce({ + user: userFixture, + access_token: 'access_token', + refresh_token: 'refreshToken2', + }); + await publicWorkos.userManagement.authenticateWithRefreshToken({ + clientId: 'client_123', + refreshToken: 'refresh_token1', + organizationId: 'org_123', + }); + + expect(fetchBody()).toEqual({ + client_id: 'client_123', + refresh_token: 'refresh_token1', + grant_type: 'refresh_token', + organization_id: 'org_123', + }); + }); + + it('throws error when session sealing is requested', async () => { + fetchOnce({ + user: userFixture, + access_token: 'access_token', + refresh_token: 'refreshToken2', + }); + + await expect( + publicWorkos.userManagement.authenticateWithRefreshToken({ + clientId: 'client_123', + refreshToken: 'refresh_token1', + session: { sealSession: true, cookiePassword: 'secret' }, + }), + ).rejects.toThrow( + 'Session sealing requires server-side usage with an API key', + ); + }); + }); }); describe('authenticateWithTotp', () => { @@ -981,7 +1221,10 @@ describe('UserManagement', () => { it('returns authenticated = false when the JWT is invalid', async () => { jest.mocked(jose.jwtVerify).mockImplementationOnce(() => { - throw new Error('Invalid JWT'); + // Simulate a jose JWT validation error with the expected code property + const error = new Error('Invalid JWT'); + (error as Error & { code: string }).code = 'ERR_JWT_INVALID'; + throw error; }); const cookiePassword = 'alongcookiesecretmadefortestingsessions'; @@ -1006,6 +1249,34 @@ describe('UserManagement', () => { ).resolves.toEqual({ authenticated: false, reason: 'invalid_jwt' }); }); + it('rethrows non-JWT errors (e.g., network failures)', async () => { + jest.mocked(jose.jwtVerify).mockImplementationOnce(() => { + // Simulate a network error (no jose error code) + throw new Error('Network error: JWKS fetch failed'); + }); + + const cookiePassword = 'alongcookiesecretmadefortestingsessions'; + const sessionData = await sealData( + { + accessToken: 'abc123', + refreshToken: 'def456', + user: { + object: 'user', + id: 'user_01H5JQDV7R7ATEYZDEG0W5PRYS', + email: 'test@example.com', + }, + }, + { password: cookiePassword }, + ); + + await expect( + workos.userManagement.authenticateWithSessionCookie({ + sessionData, + cookiePassword, + }), + ).rejects.toThrow('Network error: JWKS fetch failed'); + }); + it('returns the JWT claims when provided a valid JWT', async () => { jest .mocked(jose.jwtVerify) @@ -2129,25 +2400,10 @@ describe('UserManagement', () => { clientId: 'proj_123', redirectUri: 'example.com/auth/workos/callback', screenHint: 'sign-up', + state: 'test-state', }); - expect(url).toMatchSnapshot(); - }); - }); - - describe('with a code_challenge and code_challenge_method', () => { - it('generates an authorize url', () => { - const workos = new WorkOS('sk_test_Sz3IQjepeSWaI4cMS4ms4sMuU'); - - const url = workos.userManagement.getAuthorizationUrl({ - provider: 'authkit', - clientId: 'proj_123', - redirectUri: 'example.com/auth/workos/callback', - codeChallenge: 'code_challenge_value', - codeChallengeMethod: 'S256', - }); - - expect(url).toMatchSnapshot(); + expect(url).toContain('screen_hint=sign-up'); }); }); @@ -2159,9 +2415,12 @@ describe('UserManagement', () => { provider: 'GoogleOAuth', clientId: 'proj_123', redirectUri: 'example.com/auth/workos/callback', + state: 'test-state', }); - expect(url).toMatchSnapshot(); + expect(url).toContain( + 'https://api.workos.com/user_management/authorize', + ); }); }); @@ -2169,13 +2428,14 @@ describe('UserManagement', () => { it('throws an error for incomplete arguments', () => { const workos = new WorkOS('sk_test_Sz3IQjepeSWaI4cMS4ms4sMuU'); - const urlFn = () => + expect(() => workos.userManagement.getAuthorizationUrl({ clientId: 'proj_123', redirectUri: 'example.com/auth/workos/callback', - }); - - expect(urlFn).toThrowErrorMatchingSnapshot(); + }), + ).toThrow( + `Incomplete arguments. Need to specify either a 'connectionId', 'organizationId', or 'provider'.`, + ); }); }); @@ -2187,9 +2447,10 @@ describe('UserManagement', () => { provider: 'GoogleOAuth', clientId: 'proj_123', redirectUri: 'example.com/auth/workos/callback', + state: 'test-state', }); - expect(url).toMatchSnapshot(); + expect(url).toContain('provider=GoogleOAuth'); }); describe('with providerScopes', () => { @@ -2204,9 +2465,10 @@ describe('UserManagement', () => { ], clientId: 'proj_123', redirectUri: 'example.com/auth/workos/callback', + state: 'test-state', }); - expect(url).toMatchSnapshot(); + expect(url).toContain('provider_scopes'); }); describe('with providerQueryParams', () => { @@ -2222,8 +2484,9 @@ describe('UserManagement', () => { baz: 123, bool: true, }, + state: 'test-state', }); - expect(url).toMatchSnapshot(); + expect(url).toContain('provider_query_params'); }); }); }); @@ -2237,9 +2500,10 @@ describe('UserManagement', () => { connectionId: 'connection_123', clientId: 'proj_123', redirectUri: 'example.com/auth/workos/callback', + state: 'test-state', }); - expect(url).toMatchSnapshot(); + expect(url).toContain('connection_id=connection_123'); }); describe('with providerScopes', () => { @@ -2251,9 +2515,10 @@ describe('UserManagement', () => { providerScopes: ['read_api', 'read_repository'], clientId: 'proj_123', redirectUri: 'example.com/auth/workos/callback', + state: 'test-state', }); - expect(url).toMatchSnapshot(); + expect(url).toContain('provider_scopes'); }); }); }); @@ -2266,9 +2531,10 @@ describe('UserManagement', () => { organizationId: 'organization_123', clientId: 'proj_123', redirectUri: 'example.com/auth/workos/callback', + state: 'test-state', }); - expect(url).toMatchSnapshot(); + expect(url).toContain('organization_id=organization_123'); }); }); @@ -2282,9 +2548,12 @@ describe('UserManagement', () => { organizationId: 'organization_123', clientId: 'proj_123', redirectUri: 'example.com/auth/workos/callback', + state: 'test-state', }); - expect(url).toMatchSnapshot(); + expect(url).toContain( + 'https://api.workos.dev/user_management/authorize', + ); }); }); @@ -2299,7 +2568,7 @@ describe('UserManagement', () => { state: 'custom state', }); - expect(url).toMatchSnapshot(); + expect(url).toContain('state=custom+state'); }); }); @@ -2315,9 +2584,7 @@ describe('UserManagement', () => { state: 'custom state', }); - expect(url).toMatchInlineSnapshot( - `"https://api.workos.com/user_management/authorize?client_id=proj_123&connection_id=connection_123&domain_hint=example.com&redirect_uri=example.com%2Fauth%2Fworkos%2Fcallback&response_type=code&state=custom+state"`, - ); + expect(url).toContain('domain_hint=example.com'); }); }); @@ -2333,9 +2600,7 @@ describe('UserManagement', () => { state: 'custom state', }); - expect(url).toMatchInlineSnapshot( - `"https://api.workos.com/user_management/authorize?client_id=proj_123&connection_id=connection_123&login_hint=foo%40workos.com&redirect_uri=example.com%2Fauth%2Fworkos%2Fcallback&response_type=code&state=custom+state"`, - ); + expect(url).toContain('login_hint=foo%40workos.com'); }); }); @@ -2351,9 +2616,7 @@ describe('UserManagement', () => { state: 'custom state', }); - expect(url).toMatchInlineSnapshot( - `"https://api.workos.com/user_management/authorize?client_id=proj_123&connection_id=connection_123&prompt=login&redirect_uri=example.com%2Fauth%2Fworkos%2Fcallback&response_type=code&state=custom+state"`, - ); + expect(url).toContain('prompt=login'); }); it('generates an authorize url with consent prompt', () => { @@ -2364,13 +2627,125 @@ describe('UserManagement', () => { provider: 'GoogleOAuth', clientId: 'proj_123', redirectUri: 'example.com/auth/workos/callback', + state: 'test-state', }); - expect(url).toMatchInlineSnapshot( - `"https://api.workos.com/user_management/authorize?client_id=proj_123&prompt=consent&provider=GoogleOAuth&redirect_uri=example.com%2Fauth%2Fworkos%2Fcallback&response_type=code"`, - ); + expect(url).toContain('prompt=consent'); }); }); + + describe('with PKCE parameters (manual)', () => { + it('includes codeChallenge and codeChallengeMethod in URL', () => { + const workos = new WorkOS('sk_test_Sz3IQjepeSWaI4cMS4ms4sMuU'); + + const url = workos.userManagement.getAuthorizationUrl({ + provider: 'authkit', + clientId: 'proj_123', + redirectUri: 'example.com/auth/workos/callback', + state: 'test-state', + codeChallenge: 'test-challenge', + codeChallengeMethod: 'S256', + }); + + expect(url).toContain('code_challenge=test-challenge'); + expect(url).toContain('code_challenge_method=S256'); + }); + }); + }); + + describe('getAuthorizationUrlWithPKCE', () => { + let publicWorkos: WorkOS; + let originalApiKey: string | undefined; + + beforeEach(() => { + originalApiKey = process.env.WORKOS_API_KEY; + delete process.env.WORKOS_API_KEY; + publicWorkos = new WorkOS({ clientId: 'proj_123' }); + }); + + afterEach(() => { + if (originalApiKey) { + process.env.WORKOS_API_KEY = originalApiKey; + } + }); + + it('generates PKCE parameters and returns codeVerifier', async () => { + const result = + await publicWorkos.userManagement.getAuthorizationUrlWithPKCE({ + provider: 'authkit', + clientId: 'proj_123', + redirectUri: 'example.com/auth/workos/callback', + }); + + expect(result.codeVerifier).toBeDefined(); + expect(result.codeVerifier.length).toBeGreaterThanOrEqual(43); + expect(result.url).toContain('code_challenge='); + expect(result.url).toContain('code_challenge_method=S256'); + }); + + it('auto-generates state parameter', async () => { + const result = + await publicWorkos.userManagement.getAuthorizationUrlWithPKCE({ + provider: 'authkit', + clientId: 'proj_123', + redirectUri: 'example.com/auth/workos/callback', + }); + + expect(result.state).toBeDefined(); + expect(result.state.length).toBeGreaterThanOrEqual(43); + expect(result.url).toContain('state='); + }); + + it('includes all standard URL parameters', async () => { + const result = + await publicWorkos.userManagement.getAuthorizationUrlWithPKCE({ + provider: 'authkit', + clientId: 'proj_123', + redirectUri: 'example.com/auth/workos/callback', + screenHint: 'sign-up', + loginHint: 'test@example.com', + domainHint: 'example.com', + }); + + expect(result.url).toContain('provider=authkit'); + expect(result.url).toContain('screen_hint=sign-up'); + expect(result.url).toContain('login_hint=test%40example.com'); + expect(result.url).toContain('domain_hint=example.com'); + }); + + it('throws error when missing provider/connection/organization', async () => { + await expect( + publicWorkos.userManagement.getAuthorizationUrlWithPKCE({ + clientId: 'proj_123', + redirectUri: 'example.com/auth/workos/callback', + }), + ).rejects.toThrow( + `Incomplete arguments. Need to specify either a 'connectionId', 'organizationId', or 'provider'.`, + ); + }); + + it('uses clientId from constructor when not provided in options', async () => { + const result = + await publicWorkos.userManagement.getAuthorizationUrlWithPKCE({ + provider: 'authkit', + redirectUri: 'example.com/auth/workos/callback', + }); + + expect(result.url).toContain('client_id=proj_123'); + }); + + it('throws error when clientId not provided anywhere', async () => { + // Use confidential client (API key) without clientId to test the error + const workosNoClientId = new WorkOS('sk_test_no_client_id'); + await expect( + workosNoClientId.userManagement.getAuthorizationUrlWithPKCE({ + provider: 'authkit', + redirectUri: 'example.com/auth/workos/callback', + }), + ).rejects.toThrow( + 'clientId is required. Provide it in method options or when initializing WorkOS.', + ); + }); }); describe('getLogoutUrl', () => { diff --git a/src/user-management/user-management.ts b/src/user-management/user-management.ts index 1569cf9db..1f4c2e3fb 100644 --- a/src/user-management/user-management.ts +++ b/src/user-management/user-management.ts @@ -1,9 +1,9 @@ import { sealData, unsealData } from '../common/crypto/seal'; -import * as clientUserManagement from '../client/user-management'; import { PaginationOptions } from '../common/interfaces/pagination-options.interface'; import { fetchAndDeserialize } from '../common/utils/fetch-and-deserialize'; import { AutoPaginatable } from '../common/utils/pagination'; import { getEnv } from '../common/utils/env'; +import { toQueryString } from '../common/utils/query-string'; import { Challenge, ChallengeResponse } from '../mfa/interfaces'; import { deserializeChallenge } from '../mfa/serializers'; import { @@ -32,6 +32,7 @@ import { ListSessionsOptions, ListUsersOptions, ListUserFeatureFlagsOptions, + LogoutURLOptions, MagicAuth, MagicAuthResponse, PasswordReset, @@ -43,6 +44,7 @@ import { SerializedAuthenticateWithMagicAuthOptions, SerializedAuthenticateWithPasswordOptions, SerializedAuthenticateWithRefreshTokenOptions, + SerializedAuthenticateWithRefreshTokenPublicClientOptions, SerializedAuthenticateWithTotpOptions, SerializedCreateMagicAuthOptions, SerializedCreatePasswordResetOptions, @@ -74,7 +76,10 @@ import { AuthenticateWithSessionCookieSuccessResponse, SessionCookieData, } from './interfaces/authenticate-with-session-cookie.interface'; -import { UserManagementAuthorizationURLOptions } from './interfaces/authorization-url-options.interface'; +import { + PKCEAuthorizationURLResult, + UserManagementAuthorizationURLOptions, +} from './interfaces/authorization-url-options.interface'; import { CreateOrganizationMembershipOptions, SerializedCreateOrganizationMembershipOptions, @@ -129,6 +134,7 @@ import { serializeAuthenticateWithMagicAuthOptions, serializeAuthenticateWithPasswordOptions, serializeAuthenticateWithRefreshTokenOptions, + serializeAuthenticateWithRefreshTokenPublicClientOptions, serializeAuthenticateWithTotpOptions, serializeCreateMagicAuthOptions, serializeCreatePasswordResetOptions, @@ -165,6 +171,20 @@ export class UserManagement { this.clientId = clientId; } + /** + * Resolve clientId from method options or fall back to constructor-provided value. + * @throws TypeError if clientId is not available from either source + */ + private resolveClientId(clientId?: string): string { + const resolved = clientId ?? this.clientId; + if (!resolved) { + throw new TypeError( + 'clientId is required. Provide it in method options or when initializing WorkOS.', + ); + } + return resolved; + } + async getJWKS(): Promise< ReturnType | undefined > { @@ -245,7 +265,8 @@ export class UserManagement { async authenticateWithMagicAuth( payload: AuthenticateWithMagicAuthOptions, ): Promise { - const { session, ...remainingPayload } = payload; + const { session, clientId, ...remainingPayload } = payload; + const resolvedClientId = this.resolveClientId(clientId); const { data } = await this.workos.post< AuthenticationResponseResponse, @@ -254,6 +275,7 @@ export class UserManagement { '/user_management/authenticate', serializeAuthenticateWithMagicAuthOptions({ ...remainingPayload, + clientId: resolvedClientId, clientSecret: this.workos.key, }), ); @@ -267,7 +289,8 @@ export class UserManagement { async authenticateWithPassword( payload: AuthenticateWithPasswordOptions, ): Promise { - const { session, ...remainingPayload } = payload; + const { session, clientId, ...remainingPayload } = payload; + const resolvedClientId = this.resolveClientId(clientId); const { data } = await this.workos.post< AuthenticationResponseResponse, @@ -276,6 +299,7 @@ export class UserManagement { '/user_management/authenticate', serializeAuthenticateWithPasswordOptions({ ...remainingPayload, + clientId: resolvedClientId, clientSecret: this.workos.key, }), ); @@ -286,10 +310,42 @@ export class UserManagement { }); } + /** + * Exchange an authorization code for tokens. + * + * Auto-detects public vs confidential client mode: + * - If codeVerifier is provided: Uses PKCE flow (public client) + * - If no codeVerifier: Uses client_secret from API key (confidential client) + * - If both: Uses both client_secret AND codeVerifier (confidential client with PKCE) + * + * Using PKCE with confidential clients is recommended by OAuth 2.1 for defense + * in depth and provides additional CSRF protection on the authorization flow. + * + * @throws Error if neither codeVerifier nor API key is available + */ async authenticateWithCode( payload: AuthenticateWithCodeOptions, ): Promise { - const { session, ...remainingPayload } = payload; + const { session, clientId, codeVerifier, ...remainingPayload } = payload; + const resolvedClientId = this.resolveClientId(clientId); + + // Validate codeVerifier is not an empty string (common mistake) + if (codeVerifier !== undefined && codeVerifier.trim() === '') { + throw new TypeError( + 'codeVerifier cannot be an empty string. ' + + 'Generate a valid PKCE pair using workos.pkce.generate().', + ); + } + + const hasApiKey = !!this.workos.key; + const hasPKCE = !!codeVerifier; + + if (!hasPKCE && !hasApiKey) { + throw new TypeError( + 'authenticateWithCode requires either a codeVerifier (for public clients) ' + + 'or an API key configured on the WorkOS instance (for confidential clients).', + ); + } const { data } = await this.workos.post< AuthenticationResponseResponse, @@ -298,8 +354,11 @@ export class UserManagement { '/user_management/authenticate', serializeAuthenticateWithCodeOptions({ ...remainingPayload, - clientSecret: this.workos.key, + clientId: resolvedClientId, + codeVerifier, + clientSecret: hasApiKey ? this.workos.key : undefined, }), + { skipApiKeyCheck: !hasApiKey }, ); return this.prepareAuthenticationResponse({ @@ -308,17 +367,31 @@ export class UserManagement { }); } + /** + * Exchange an authorization code for tokens using PKCE (public client flow). + * Use this instead of authenticateWithCode() when the client cannot securely + * store a client_secret (browser, mobile, CLI, desktop apps). + * + * @param payload.clientId - Your WorkOS client ID + * @param payload.code - The authorization code from the OAuth callback + * @param payload.codeVerifier - The PKCE code verifier used to generate the code challenge + */ async authenticateWithCodeAndVerifier( payload: AuthenticateWithCodeAndVerifierOptions, ): Promise { - const { session, ...remainingPayload } = payload; + const { session, clientId, ...remainingPayload } = payload; + const resolvedClientId = this.resolveClientId(clientId); const { data } = await this.workos.post< AuthenticationResponseResponse, SerializedAuthenticateWithCodeAndVerifierOptions >( '/user_management/authenticate', - serializeAuthenticateWithCodeAndVerifierOptions(remainingPayload), + serializeAuthenticateWithCodeAndVerifierOptions({ + ...remainingPayload, + clientId: resolvedClientId, + }), + { skipApiKeyCheck: true }, ); return this.prepareAuthenticationResponse({ @@ -327,21 +400,36 @@ export class UserManagement { }); } + /** + * Refresh an access token using a refresh token. + * Automatically detects public client mode - if no API key is configured, + * omits client_secret from the request. + */ async authenticateWithRefreshToken( payload: AuthenticateWithRefreshTokenOptions, ): Promise { - const { session, ...remainingPayload } = payload; + const { session, clientId, ...remainingPayload } = payload; + const resolvedClientId = this.resolveClientId(clientId); + const isPublicClient = !this.workos.key; + + const body = isPublicClient + ? serializeAuthenticateWithRefreshTokenPublicClientOptions({ + ...remainingPayload, + clientId: resolvedClientId, + }) + : serializeAuthenticateWithRefreshTokenOptions({ + ...remainingPayload, + clientId: resolvedClientId, + clientSecret: this.workos.key, + }); const { data } = await this.workos.post< AuthenticationResponseResponse, - SerializedAuthenticateWithRefreshTokenOptions - >( - '/user_management/authenticate', - serializeAuthenticateWithRefreshTokenOptions({ - ...remainingPayload, - clientSecret: this.workos.key, - }), - ); + | SerializedAuthenticateWithRefreshTokenOptions + | SerializedAuthenticateWithRefreshTokenPublicClientOptions + >('/user_management/authenticate', body, { + skipApiKeyCheck: isPublicClient, + }); return this.prepareAuthenticationResponse({ authenticationResponse: deserializeAuthenticationResponse(data), @@ -352,7 +440,8 @@ export class UserManagement { async authenticateWithTotp( payload: AuthenticateWithTotpOptions, ): Promise { - const { session, ...remainingPayload } = payload; + const { session, clientId, ...remainingPayload } = payload; + const resolvedClientId = this.resolveClientId(clientId); const { data } = await this.workos.post< AuthenticationResponseResponse, @@ -361,6 +450,7 @@ export class UserManagement { '/user_management/authenticate', serializeAuthenticateWithTotpOptions({ ...remainingPayload, + clientId: resolvedClientId, clientSecret: this.workos.key, }), ); @@ -374,7 +464,8 @@ export class UserManagement { async authenticateWithEmailVerification( payload: AuthenticateWithEmailVerificationOptions, ): Promise { - const { session, ...remainingPayload } = payload; + const { session, clientId, ...remainingPayload } = payload; + const resolvedClientId = this.resolveClientId(clientId); const { data } = await this.workos.post< AuthenticationResponseResponse, @@ -383,6 +474,7 @@ export class UserManagement { '/user_management/authenticate', serializeAuthenticateWithEmailVerificationOptions({ ...remainingPayload, + clientId: resolvedClientId, clientSecret: this.workos.key, }), ); @@ -396,7 +488,8 @@ export class UserManagement { async authenticateWithOrganizationSelection( payload: AuthenticateWithOrganizationSelectionOptions, ): Promise { - const { session, ...remainingPayload } = payload; + const { session, clientId, ...remainingPayload } = payload; + const resolvedClientId = this.resolveClientId(clientId); const { data } = await this.workos.post< AuthenticationResponseResponse, @@ -405,6 +498,7 @@ export class UserManagement { '/user_management/authenticate', serializeAuthenticateWithOrganizationSelectionOptions({ ...remainingPayload, + clientId: resolvedClientId, clientSecret: this.workos.key, }), ); @@ -497,7 +591,17 @@ export class UserManagement { await jwtVerify(accessToken, jwks); return true; } catch (e) { - return false; + // Only treat as invalid JWT if it's an actual JWT/JWS error from jose + // Network errors, crypto failures, etc. should propagate + if ( + e instanceof Error && + 'code' in e && + typeof e.code === 'string' && + (e.code.startsWith('ERR_JWT_') || e.code.startsWith('ERR_JWS_')) + ) { + return false; + } + throw e; } } @@ -509,6 +613,14 @@ export class UserManagement { session?: AuthenticateWithSessionOptions; }): Promise { if (session?.sealSession) { + if (!this.workos.key) { + throw new Error( + 'Session sealing requires server-side usage with an API key. ' + + 'Public clients should store tokens directly ' + + '(e.g., secure storage on mobile, keychain on desktop).', + ); + } + return { ...authenticationResponse, sealedSession: await this.sealSessionDataFromAuthenticationResponse({ @@ -965,24 +1077,181 @@ export class UserManagement { ); } + /** + * Generate an OAuth 2.0 authorization URL. + * + * For public clients (browser, mobile, CLI), include PKCE parameters: + * - Generate PKCE using workos.pkce.generate() + * - Pass codeChallenge and codeChallengeMethod here + * - Store codeVerifier and pass to authenticateWithCode() later + * + * Or use getAuthorizationUrlWithPKCE() which handles PKCE automatically. + */ getAuthorizationUrl(options: UserManagementAuthorizationURLOptions): string { - // Delegate to client implementation - return clientUserManagement.getAuthorizationUrl({ - ...options, - baseURL: this.workos.baseURL, + const { + connectionId, + codeChallenge, + codeChallengeMethod, + clientId, + domainHint, + loginHint, + organizationId, + provider, + providerQueryParams, + providerScopes, + prompt, + redirectUri, + state, + screenHint, + } = options; + const resolvedClientId = this.resolveClientId(clientId); + + if (!provider && !connectionId && !organizationId) { + throw new TypeError( + `Incomplete arguments. Need to specify either a 'connectionId', 'organizationId', or 'provider'.`, + ); + } + + if (provider !== 'authkit' && screenHint) { + throw new TypeError( + `'screenHint' is only supported for 'authkit' provider`, + ); + } + + const query = toQueryString({ + connection_id: connectionId, + code_challenge: codeChallenge, + code_challenge_method: codeChallengeMethod, + organization_id: organizationId, + domain_hint: domainHint, + login_hint: loginHint, + provider, + provider_query_params: providerQueryParams, + provider_scopes: providerScopes, + prompt, + client_id: resolvedClientId, + redirect_uri: redirectUri, + response_type: 'code', + state, + screen_hint: screenHint, }); + + return `${this.workos.baseURL}/user_management/authorize?${query}`; } - getLogoutUrl(options: clientUserManagement.LogoutURLOptions): string { - // Delegate to client implementation - return clientUserManagement.getLogoutUrl({ - ...options, - baseURL: this.workos.baseURL, + /** + * Generate an OAuth 2.0 authorization URL with automatic PKCE. + * + * This method generates PKCE parameters internally and returns them along with + * the authorization URL. Use this for public clients (CLI apps, Electron, mobile) + * that cannot securely store a client secret. + * + * @returns Object containing url, state, and codeVerifier + * + * @example + * ```typescript + * const { url, state, codeVerifier } = await workos.userManagement.getAuthorizationUrlWithPKCE({ + * provider: 'authkit', + * clientId: 'client_123', + * redirectUri: 'myapp://callback', + * }); + * + * // Store state and codeVerifier securely, then redirect user to url + * // After callback, exchange the code: + * const response = await workos.userManagement.authenticateWithCode({ + * code: authorizationCode, + * codeVerifier, + * clientId: 'client_123', + * }); + * ``` + */ + async getAuthorizationUrlWithPKCE( + options: Omit< + UserManagementAuthorizationURLOptions, + 'codeChallenge' | 'codeChallengeMethod' | 'state' + >, + ): Promise { + const { + clientId, + connectionId, + domainHint, + loginHint, + organizationId, + provider, + providerQueryParams, + providerScopes, + prompt, + redirectUri, + screenHint, + } = options; + const resolvedClientId = this.resolveClientId(clientId); + + if (!provider && !connectionId && !organizationId) { + throw new TypeError( + `Incomplete arguments. Need to specify either a 'connectionId', 'organizationId', or 'provider'.`, + ); + } + + if (provider !== 'authkit' && screenHint) { + throw new TypeError( + `'screenHint' is only supported for 'authkit' provider`, + ); + } + + // Generate PKCE parameters + const pkce = await this.workos.pkce.generate(); + + // Generate secure random state + const state = this.workos.pkce.generateCodeVerifier(43); + + const query = toQueryString({ + connection_id: connectionId, + code_challenge: pkce.codeChallenge, + code_challenge_method: 'S256', + organization_id: organizationId, + domain_hint: domainHint, + login_hint: loginHint, + provider, + provider_query_params: providerQueryParams, + provider_scopes: providerScopes, + prompt, + client_id: resolvedClientId, + redirect_uri: redirectUri, + response_type: 'code', + state, + screen_hint: screenHint, }); + + const url = `${this.workos.baseURL}/user_management/authorize?${query}`; + + return { url, state, codeVerifier: pkce.codeVerifier }; + } + + getLogoutUrl(options: LogoutURLOptions): string { + const { sessionId, returnTo } = options; + + if (!sessionId) { + throw new TypeError(`Incomplete arguments. Need to specify 'sessionId'.`); + } + + const url = new URL( + '/user_management/sessions/logout', + this.workos.baseURL, + ); + + url.searchParams.set('session_id', sessionId); + if (returnTo) { + url.searchParams.set('return_to', returnTo); + } + + return url.toString(); } getJwksUrl(clientId: string): string { - // Delegate to client implementation - return clientUserManagement.getJwksUrl(clientId, this.workos.baseURL); + if (!clientId) { + throw new TypeError('clientId must be a valid clientId'); + } + + return `${this.workos.baseURL}/sso/jwks/${clientId}`; } } diff --git a/src/workos.spec.ts b/src/workos.spec.ts index 1ef9c355e..08b6a3516 100644 --- a/src/workos.spec.ts +++ b/src/workos.spec.ts @@ -1,8 +1,8 @@ import fetch from 'jest-fetch-mock'; import { fetchOnce, fetchHeaders, fetchBody } from './common/utils/test-utils'; import { + ApiKeyRequiredException, GenericServerException, - NoApiKeyProvidedException, NotFoundException, OauthException, } from './common/exceptions'; @@ -36,10 +36,37 @@ describe('WorkOS', () => { process.env = OLD_ENV; }); - describe('when no API key is provided', () => { - it('throws a NoApiKeyFoundException error', async () => { + describe('when no API key AND no clientId is provided', () => { + it('throws an error explaining both instantiation modes', async () => { delete process.env.WORKOS_API_KEY; - expect(() => new WorkOS()).toThrow(NoApiKeyProvidedException); + delete process.env.WORKOS_CLIENT_ID; + expect(() => new WorkOS()).toThrow( + 'WorkOS requires either an API key or a clientId', + ); + }); + }); + + describe('when only clientId is provided (public client mode)', () => { + it('initializes successfully without API key', async () => { + delete process.env.WORKOS_API_KEY; + const workos = new WorkOS({ clientId: 'client_123' }); + expect(workos.clientId).toBe('client_123'); + expect(workos.key).toBeUndefined(); + }); + + it('initializes with clientId from environment variable', async () => { + delete process.env.WORKOS_API_KEY; + process.env.WORKOS_CLIENT_ID = 'client_from_env'; + const workos = new WorkOS(); + expect(workos.clientId).toBe('client_from_env'); + }); + + it('does not include Authorization header in HTTP client', async () => { + delete process.env.WORKOS_API_KEY; + const workos = new WorkOS({ clientId: 'client_123' }); + // HTTP client should be created without Authorization header + // We can't easily test this directly, but we verify the workos.key is undefined + expect(workos.key).toBeUndefined(); }); }); @@ -58,6 +85,26 @@ describe('WorkOS', () => { }); }); + describe('when API key is provided via options object', () => { + it('initializes with apiKey in options', async () => { + delete process.env.WORKOS_API_KEY; + const workos = new WorkOS({ + apiKey: 'sk_test_Sz3IQjepeSWaI4cMS4ms4sMuU', + }); + expect(workos.key).toBe('sk_test_Sz3IQjepeSWaI4cMS4ms4sMuU'); + }); + + it('allows both apiKey and clientId in options', async () => { + delete process.env.WORKOS_API_KEY; + const workos = new WorkOS({ + apiKey: 'sk_test_Sz3IQjepeSWaI4cMS4ms4sMuU', + clientId: 'client_123', + }); + expect(workos.key).toBe('sk_test_Sz3IQjepeSWaI4cMS4ms4sMuU'); + expect(workos.clientId).toBe('client_123'); + }); + }); + describe('with https option', () => { it('sets baseURL', () => { const workos = new WorkOS('foo', { https: false }); @@ -156,7 +203,64 @@ describe('WorkOS', () => { }); }); + describe('pkce', () => { + it('is available as a property', () => { + const workos = new WorkOS('sk_test_Sz3IQjepeSWaI4cMS4ms4sMuU'); + expect(workos.pkce).toBeDefined(); + }); + + it('is available in PKCE-only mode', () => { + delete process.env.WORKOS_API_KEY; + const workos = new WorkOS({ clientId: 'client_123' }); + expect(workos.pkce).toBeDefined(); + }); + }); + + describe('requireApiKey', () => { + it('does not throw when API key is provided', () => { + const workos = new WorkOS('sk_test_Sz3IQjepeSWaI4cMS4ms4sMuU'); + expect(() => workos.requireApiKey('someMethod')).not.toThrow(); + }); + + it('throws ApiKeyRequiredException when no API key (public client mode)', () => { + delete process.env.WORKOS_API_KEY; + const workos = new WorkOS({ clientId: 'client_123' }); + expect(() => workos.requireApiKey('listOrganizations')).toThrow( + ApiKeyRequiredException, + ); + }); + + it('includes path in error message', () => { + delete process.env.WORKOS_API_KEY; + const workos = new WorkOS({ clientId: 'client_123' }); + expect(() => workos.requireApiKey('/organizations')).toThrow( + 'API key required for "/organizations"', + ); + }); + }); + describe('post', () => { + describe('when no API key is provided (public client mode)', () => { + it('throws ApiKeyRequiredException', async () => { + delete process.env.WORKOS_API_KEY; + const workos = new WorkOS({ clientId: 'client_123' }); + + await expect(workos.post('/path', {})).rejects.toThrow( + ApiKeyRequiredException, + ); + }); + + it('allows bypass with skipApiKeyCheck option', async () => { + delete process.env.WORKOS_API_KEY; + fetchOnce('{}'); + const workos = new WorkOS({ clientId: 'client_123' }); + + await expect( + workos.post('/path', {}, { skipApiKeyCheck: true }), + ).resolves.toBeDefined(); + }); + }); + describe('when the api responds with a 404', () => { it('throws a NotFoundException', async () => { const message = 'Not Found'; @@ -315,6 +419,17 @@ describe('WorkOS', () => { }); describe('get', () => { + describe('when no API key is provided (public client mode)', () => { + it('throws ApiKeyRequiredException', async () => { + delete process.env.WORKOS_API_KEY; + const workos = new WorkOS({ clientId: 'client_123' }); + + await expect(workos.get('/path')).rejects.toThrow( + ApiKeyRequiredException, + ); + }); + }); + describe('when the api responds with invalid JSON', () => { it('throws a ParseError', async () => { const mockResponse = { diff --git a/src/workos.ts b/src/workos.ts index cdea3fb01..84ac43fc9 100644 --- a/src/workos.ts +++ b/src/workos.ts @@ -1,12 +1,13 @@ import { + ApiKeyRequiredException, GenericServerException, - NoApiKeyProvidedException, NotFoundException, UnauthorizedException, UnprocessableEntityException, OauthException, RateLimitExceededException, } from './common/exceptions'; +import { PKCE } from './pkce/pkce'; import { GetOptions, HttpClientResponseInterface, @@ -55,6 +56,11 @@ export class WorkOS { readonly baseURL: string; readonly client: HttpClient; readonly clientId?: string; + readonly key?: string; + readonly options: WorkOSOptions; + readonly pkce: PKCE; + + private readonly hasApiKey: boolean; readonly actions: Actions; readonly apiKeys = new ApiKeys(this); @@ -74,18 +80,42 @@ export class WorkOS { readonly widgets = new Widgets(this); readonly vault = new Vault(this); + /** + * Create a new WorkOS client. + * + * @param keyOrOptions - API key string, or options object + * @param maybeOptions - Options when first argument is API key + * + * @example + * // Server-side with API key (string) + * const workos = new WorkOS('sk_...'); + * + * @example + * // Server-side with API key (object) + * const workos = new WorkOS({ apiKey: 'sk_...', clientId: 'client_...' }); + * + * @example + * // PKCE/public client (no API key) + * const workos = new WorkOS({ clientId: 'client_...' }); + */ constructor( - readonly key?: string, - readonly options: WorkOSOptions = {}, + keyOrOptions?: string | WorkOSOptions, + maybeOptions?: WorkOSOptions, ) { - if (!key) { - this.key = getEnv('WORKOS_API_KEY'); + if (typeof keyOrOptions === 'object') { + this.key = keyOrOptions.apiKey; + this.options = keyOrOptions; + } else { + this.key = keyOrOptions; + this.options = maybeOptions ?? {}; + } - if (!this.key) { - throw new NoApiKeyProvidedException(); - } + if (!this.key) { + this.key = getEnv('WORKOS_API_KEY'); } + this.hasApiKey = !!this.key; + if (this.options.https === undefined) { this.options.https = true; } @@ -95,6 +125,14 @@ export class WorkOS { this.clientId = getEnv('WORKOS_CLIENT_ID'); } + if (!this.hasApiKey && !this.clientId) { + throw new Error( + 'WorkOS requires either an API key or a clientId. ' + + 'For server-side: new WorkOS("sk_...") or new WorkOS({ apiKey: "sk_..." }). ' + + 'For PKCE/public clients: new WorkOS({ clientId: "client_..." })', + ); + } + const protocol: string = this.options.https ? 'https' : 'http'; const apiHostname: string = this.options.apiHostname || DEFAULT_HOSTNAME; const port: number | undefined = this.options.port; @@ -104,15 +142,17 @@ export class WorkOS { this.baseURL = this.baseURL + `:${port}`; } + this.pkce = new PKCE(); + this.webhooks = this.createWebhookClient(); this.actions = this.createActionsClient(); // Must initialize UserManagement after baseURL is configured this.userManagement = new UserManagement(this); - const userAgent = this.createUserAgent(options); + const userAgent = this.createUserAgent(this.options); - this.client = this.createHttpClient(options, userAgent); + this.client = this.createHttpClient(this.options, userAgent); } private createUserAgent(options: WorkOSOptions): string { @@ -142,14 +182,28 @@ export class WorkOS { } createHttpClient(options: WorkOSOptions, userAgent: string) { + const headers: Record = { + 'User-Agent': userAgent, + }; + + const configHeaders = options.config?.headers; + if ( + configHeaders && + typeof configHeaders === 'object' && + !Array.isArray(configHeaders) && + !(configHeaders instanceof Headers) + ) { + Object.assign(headers, configHeaders); + } + + if (this.key) { + headers['Authorization'] = `Bearer ${this.key}`; + } + return new FetchHttpClient(this.baseURL, { ...options.config, timeout: options.timeout, - headers: { - ...options.config?.headers, - Authorization: `Bearer ${this.key}`, - 'User-Agent': userAgent, - }, + headers, }) as HttpClient; } @@ -157,11 +211,26 @@ export class WorkOS { return VERSION; } + /** + * Require API key for methods that need it. + * @param methodName - Name of the method requiring API key (for error message) + * @throws ApiKeyRequiredException if no API key was provided + */ + requireApiKey(methodName: string): void { + if (!this.hasApiKey) { + throw new ApiKeyRequiredException(methodName); + } + } + async post( path: string, entity: Entity, options: PostOptions = {}, ): Promise<{ data: Result }> { + if (!options.skipApiKeyCheck) { + this.requireApiKey(path); + } + const requestHeaders: Record = {}; if (options.idempotencyKey) { @@ -196,6 +265,10 @@ export class WorkOS { path: string, options: GetOptions = {}, ): Promise<{ data: Result }> { + if (!options.skipApiKeyCheck) { + this.requireApiKey(path); + } + const requestHeaders: Record = {}; if (options.accessToken) { @@ -231,6 +304,10 @@ export class WorkOS { entity: Entity, options: PutOptions = {}, ): Promise<{ data: Result }> { + if (!options.skipApiKeyCheck) { + this.requireApiKey(path); + } + const requestHeaders: Record = {}; if (options.idempotencyKey) { @@ -259,6 +336,8 @@ export class WorkOS { } async delete(path: string, query?: any): Promise { + this.requireApiKey(path); + try { await this.client.delete(path, { params: query,