-
Notifications
You must be signed in to change notification settings - Fork 44
[v8] feat: add PKCE support for public clients; remove /client entry point #1435
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Conversation
Enable public clients (Electron, mobile, CLI) to authenticate using PKCE
without requiring an API key.
- Make API key optional when clientId is provided
- Add PKCE class with RFC 7636 compliant utilities (generateCodeVerifier,
generateCodeChallenge, generate)
- Add ApiKeyRequiredException for methods requiring API key
- Support constructor overloading: new WorkOS({ clientId }) for PKCE mode
- Export PKCE utilities from all entry points
Guards all API calls by default, throwing ApiKeyRequiredException when no API key is provided. PKCE-safe methods bypass with skipApiKeyCheck option.
- Remove ./client package export from package.json - Inline getAuthorizationUrl, getLogoutUrl, getJwksUrl into services - Delete src/client/ directory - Add LogoutURLOptions interface to user-management
Add authenticateWithRefreshTokenPKCE() for public clients (browser/mobile) to refresh tokens without requiring a client secret. - New interface: AuthenticateWithRefreshTokenPKCEOptions - New serializer that omits client_secret from request - Method uses skipApiKeyCheck to work without API key - Completes PKCE flow: auth → token exchange → refresh indefinitely
- Remove separate authenticateWithRefreshTokenPKCE method - authenticateWithRefreshToken now auto-detects public client mode - Rename "PKCE mode" to "public client mode" in tests/comments - PKCE is just the security mechanism, not the mode itself
- Renamed SerializedAuthenticateWithPKCEBase → SerializedAuthenticatePublicClientBase - Renamed *-pkce-* files to *-public-client-* for accuracy - Improved ApiKeyRequiredException error message with actionable guidance
- Document authenticateWithCodeAndVerifier() for PKCE flow - Document getAuthorizationUrl() PKCE parameters - Add Public Client Mode section to README
Public clients cannot securely store a cookiePassword, so session sealing is not appropriate. Throw a clear error directing users to store tokens directly (secure storage, keychain, etc).
Remove verbose comments that explained obvious code. Keeps JSDoc and comments that explain non-obvious dependencies.
- Eliminate modulo bias in PKCE code verifier generation using rejection sampling (threshold 198 for uniform distribution across 66 chars) - Rethrow non-JWT errors in isValidJwt() instead of swallowing all exceptions (only treat jose ERR_JWT_* and ERR_JWS_* errors as invalid JWT) - Update tests to mock jose-like errors with proper code property - Add test verifying network errors are properly propagated
Replace custom rejection sampling with base64url encoding of random bytes. This matches RFC 7636 Appendix B and is the standard approach used by most OAuth libraries. Base64url has no bias since 64 divides evenly into 256.
- Add requireApiKey() to getProfileAndToken() to fail fast in public
client mode instead of passing undefined to the API
- Remove try-catch around unsealData() in session.ts - known seal errors
already return {} (handled by accessToken check), unknown errors should
propagate instead of being masked as "invalid session"
Add a convenience method that generates PKCE internally and returns the codeVerifier for app-controlled storage. This enables a simplified 2-step auth flow for public clients (CLI, Electron, React Native). - Add getAuthorizationUrlWithPKCE() async method - Add PKCEAuthorizationURLResult interface - Add authenticateWithCode() with auto-detect for codeVerifier - Keep existing getAuthorizationUrl() unchanged (non-breaking) - Replace snapshot tests with toContain assertions
Add PKCE (Proof Key for Code Exchange) support to SSO module for public clients like Electron apps, mobile apps, and CLI tools that cannot securely store a client secret. Changes: - Add codeChallenge/codeChallengeMethod to SSOAuthorizationURLOptions - Add getAuthorizationUrlWithPKCE() helper that auto-generates PKCE - Add codeVerifier option to getProfileAndToken() for token exchange - Auto-detect public vs confidential client mode based on codeVerifier
- Add empty codeVerifier validation to prevent silent bypass of PKCE flow - Add PKCE field coupling types (codeChallenge + codeChallengeMethod must be set together) - Standardize error types to TypeError for argument validation - Add tests for empty/whitespace codeVerifier edge cases
Greptile SummaryThis PR enables public client authentication using PKCE (Proof Key for Code Exchange) for applications that cannot securely store secrets, while removing the separate Key Changes
Security Considerations
Backward Compatibility
Confidence Score: 5/5
Important Files Changed
Sequence DiagramsequenceDiagram
participant Client as Public Client App
participant SDK as WorkOS SDK
participant Crypto as Web Crypto API
participant API as WorkOS API
participant User as User/Browser
Note over Client,API: PKCE Authorization Flow (Public Client Mode)
Client->>SDK: new WorkOS({ clientId: 'client_...' })
Note over SDK: No API key required
Client->>SDK: getAuthorizationUrlWithPKCE(options)
SDK->>Crypto: generateCodeVerifier()
Crypto-->>SDK: codeVerifier (43-128 chars)
SDK->>Crypto: SHA-256(codeVerifier)
Crypto-->>SDK: codeChallenge (base64url)
SDK-->>Client: { url, state, codeVerifier }
Client->>Client: Store codeVerifier securely
Client->>User: Redirect to authorization URL
User->>API: Authenticate
API-->>User: Redirect with code
User-->>Client: Return with code & state
Client->>SDK: authenticateWithCode({ code, codeVerifier })
Note over SDK: Detects public client mode<br/>(codeVerifier present)
SDK->>API: POST /authenticate<br/>{ code, code_verifier, client_id }
Note over SDK,API: No client_secret sent
API->>API: Verify code_verifier matches<br/>code_challenge from PKCE
API-->>SDK: { accessToken, refreshToken, user }
SDK-->>Client: Authentication response
Note over Client,API: Token Refresh Flow
Client->>SDK: authenticateWithRefreshToken({ refreshToken })
Note over SDK: Detects public client mode<br/>(no API key)
SDK->>API: POST /authenticate<br/>{ refresh_token, client_id }
Note over SDK,API: No client_secret sent
API-->>SDK: { accessToken, refreshToken }
SDK-->>Client: New tokens
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
37 files reviewed, no comments
OAuth 2.1 recommends PKCE for ALL clients, not just public ones. This change allows confidential clients (with API key) to also use PKCE for additional security and CSRF protection. Changes: - authenticateWithCode() now sends both client_secret AND code_verifier when both API key and codeVerifier are present - Same behavior for SSO getProfileAndToken() - Added tests for "confidential client with PKCE" scenario - Updated README with example for server-side PKCE usage The auto-detection logic now: - Has API key + has codeVerifier = send both (confidential + PKCE) - Has API key + no codeVerifier = send client_secret only (traditional) - No API key + has codeVerifier = send codeVerifier only (public client) - No API key + no codeVerifier = error
| * | ||
| * @example | ||
| * // PKCE/public client (no API key) | ||
| * const workos = new WorkOS({ clientId: 'client_...' }); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
i wonder if we could keep the API key mode around for backwards compat, but prefer/encourage the object mode going forward. i'm imagining that even confidential clients would initialize with:
const workos = new WorkOS({
client_id: 'client_1234',
client_secret: 'sssshhh'
});
It feels strange to me to initialize differently based on how you want to authenticate.
I would also really love it if the authkit APIs could use the clientId specified here for all authenticate calls instead of awkwardly requiring the user to specify them as arguments. (in authenticateWithPassword/getJwksUrl/etc.). (we don't necessarily need to tackle that right now though).
| throw new NoApiKeyProvidedException(); | ||
| } | ||
| if (!this.key) { | ||
| this.key = getEnv('WORKOS_API_KEY'); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
i kind of think it would be lovely if you could specify your WORKOS_CLIENT_ID via env var too.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
(i really wish client_id was required but i also see the value of having it be optional to preserve backwards compatibility).
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
also wondering if we could only attach attach the stuff that makes sense for how this was initialized (e.g. if you don't specify a client_secret, we don't attach all the CRUD objects that you currently will get a run-time error (no key) for when calling). i think this would make the typescript experience pretty awesome.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
i kind of think it would be lovely if you could specify your WORKOS_CLIENT_ID via env var too.
This is already supported.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
also wondering if we could only attach attach the stuff that makes sense for how this was initialized (e.g. if you don't specify a client_secret, we don't attach all the CRUD objects that you currently will get a run-time error (no key) for when calling). i think this would make the typescript experience pretty awesome.
I don't disagree here and shied away from it because it'd be a pretty big breaking change. Constructors can't be overloaded in TS I don't think, so the best way to apply this would probably be with a factory function. That's something we could look at adding later, but for now users will get a runtime error if using a method that requires an API Key when none is provided.
README.md
Outdated
| const { url, codeVerifier } = await workos.userManagement.getAuthorizationUrlWithPKCE({ | ||
| provider: 'authkit', | ||
| redirectUri: 'https://example.com/callback', | ||
| clientId: 'client_...', |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
this is the redundant clientId stuff that i wish we could omit given we already specified this when creating the client.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
clientId is now optional in all method parameters. If not provided, it falls back to the value from the constructor (new WorkOS('sk_...', { clientId: 'client_123' })).
Changes:
- Added
resolveClientId()helper that throws a clear error ifclientIdisn't available from either source - Updated all 10 affected methods (8 authenticate methods + 2 authorization URL methods)
- Added WithResolvedClientId utility type for type-safe serializers (no ! assertions)
- Added tests for fallback behavior
Usage:
const workos = new WorkOS('sk_...', { clientId: 'client_123' });
// clientId now optional - falls back to constructor value
await workos.userManagement.authenticateWithCode({ code });
README.md
Outdated
| const workos = new WorkOS({ clientId: 'client_...' }); // No API key needed | ||
|
|
||
| // Generate auth URL with automatic PKCE | ||
| const { url, codeVerifier } = await workos.userManagement.getAuthorizationUrlWithPKCE({ |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
should we mention that the codeVerifier should be stored on-device (cookie, phone storage, etc.) for later? (it's somewhat clear since you need it for the authenticateWithCode call, but maybe not obvious to someone who has never done this before?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Added the following to the README:
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.
Support Headers instance, string[][] tuples, and plain objects as valid header inputs per the RequestInit type contract.
Allows `new WorkOS({ apiKey: 'sk_...', clientId: '...' })` as an
alternative to `new WorkOS('sk_...', { clientId: '...' })`.
Allow clientId to be omitted from method calls when it was provided during WorkOS initialization. This reduces redundancy for developers who configure clientId once at startup. - Add resolveClientId() helper that falls back to constructor value - Update 10 methods to use extract-resolve-spread pattern - Add WithResolvedClientId<T> utility type for type-safe serializers - Add tests for fallback behavior and missing clientId error
Warns developers to store codeVerifier securely on-device between auth URL generation and callback handling. Mentions platform-specific options (iOS Keychain, Android Keystore, OS credential storage).
cmatheson
left a comment
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🎉
…point (#1435) Enable PKCE authentication for both public and confidential clients. - **API key now optional**: Initialize with just `clientId` for PKCE mode: `new WorkOS({ clientId: 'client_...' })` - **New helper method**: `getAuthorizationUrlWithPKCE()` - generates PKCE internally, returns `{ url, state, codeVerifier }` - **Enhanced exchange**: `authenticateWithCode()` auto-detects client mode based on available credentials - **Manual PKCE option**: `workos.pkce.generate()` + `getAuthorizationUrl()` for advanced use cases - **Non-breaking**: Existing `getAuthorizationUrl()` unchanged, still returns URL string Server-side apps can use PKCE alongside the client secret for defense in depth: ```ts const workos = new WorkOS('sk_...'); // With API key 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_...', }); ``` The auto-detection logic: | API Key | codeVerifier | Behavior | |---------|--------------|----------| | ✅ | ✅ | Send both `client_secret` AND `code_verifier` (confidential + PKCE) | | ✅ | ❌ | Send `client_secret` only (traditional confidential client) | | ❌ | ✅ | Send `code_verifier` only (public client) | | ❌ | ❌ | Error | The separate `/client` entry point has been removed. Instead of: ```ts // Old approach - standalone functions import { userManagement } from '@workos-inc/node/client'; const url = userManagement.getAuthorizationUrl({ ... }); ``` Use the standard SDK without an API key: ```ts // New approach - consistent with rest of SDK import { WorkOS } from '@workos-inc/node'; const workos = new WorkOS({ clientId: 'client_...' }); const url = workos.userManagement.getAuthorizationUrl({ ... }); ``` This provides a single, consistent API surface rather than two parallel approaches. ```ts import { WorkOS } from '@workos-inc/node'; const workos = new WorkOS({ clientId: 'client_...' }); // Step 1: Get authorization URL with auto-generated PKCE const { url, state, codeVerifier } = await workos.userManagement.getAuthorizationUrlWithPKCE({ redirectUri: 'myapp://callback', provider: 'authkit', }); // Store codeVerifier securely, then redirect user to url // Step 2: Exchange code for tokens const { accessToken, refreshToken, user } = await workos.userManagement.authenticateWithCode({ code: authCode, codeVerifier, }); ``` | Method | Description | |--------|-------------| | `userManagement.getAuthorizationUrlWithPKCE()` | Build OAuth URL with auto-generated PKCE | | `userManagement.getAuthorizationUrl()` | Build OAuth URL (with manual PKCE params) | | `userManagement.authenticateWithCode()` | Exchange code + verifier for tokens | | `userManagement.authenticateWithCodeAndVerifier()` | Exchange code + verifier for tokens (explicit) | | `userManagement.authenticateWithRefreshToken()` | Refresh tokens | | `userManagement.getLogoutUrl()` | Build logout redirect URL | | `userManagement.getJwksUrl()` | Get JWKS URL for local JWT validation | | `workos.pkce.generate()` | Generate PKCE code verifier and challenge |
Description
Enable PKCE authentication for both public and confidential clients.
Changes
clientIdfor PKCE mode:new WorkOS({ clientId: 'client_...' })getAuthorizationUrlWithPKCE()- generates PKCE internally, returns{ url, state, codeVerifier }authenticateWithCode()auto-detects client mode based on available credentialsworkos.pkce.generate()+getAuthorizationUrl()for advanced use casesgetAuthorizationUrl()unchanged, still returns URL stringPKCE with Confidential Clients (OAuth 2.1 Best Practice)
Server-side apps can use PKCE alongside the client secret for defense in depth:
The auto-detection logic:
client_secretANDcode_verifier(confidential + PKCE)client_secretonly (traditional confidential client)code_verifieronly (public client)Removed:
@workos-inc/node/clientexportThe separate
/cliententry point has been removed. Instead of:Use the standard SDK without an API key:
This provides a single, consistent API surface rather than two parallel approaches.
Public Client Usage
Methods Available Without API Key
userManagement.getAuthorizationUrlWithPKCE()userManagement.getAuthorizationUrl()userManagement.authenticateWithCode()userManagement.authenticateWithCodeAndVerifier()userManagement.authenticateWithRefreshToken()userManagement.getLogoutUrl()userManagement.getJwksUrl()workos.pkce.generate()