Skip to content

Commit 79cfca5

Browse files
committed
feat(@probitas/client-http): add PKCE support and remove clientSecret
Implement PKCE (Proof Key for Code Exchange, RFC 7636) for enhanced security in OIDC Authorization Code flow. PKCE is now automatically enabled using S256 method for all authentication requests. Also remove clientSecret parameter as this client is designed for Public Clients (SPAs, Native Apps) where client secrets cannot be securely stored. The focus on Public Clients aligns with the testing use case and modern OAuth 2.0 Security Best Current Practice. Additionally fix OIDC spec compliance by adding client_id to Authorization Request, which was missing in the previous implementation. Changes: - Add automatic PKCE (S256) code_challenge/code_verifier generation - Remove clientSecret from OidcClientConfig (breaking change) - Add client_id to Authorization Request (bug fix) - Update documentation to recommend issuer-based Discovery - Add "Manual Endpoint Configuration" section for legacy providers BREAKING CHANGE: clientSecret parameter removed from OidcClientConfig
1 parent 210be72 commit 79cfca5

File tree

1 file changed

+53
-17
lines changed
  • packages/probitas-client-http

1 file changed

+53
-17
lines changed

packages/probitas-client-http/oidc.ts

Lines changed: 53 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@
88
*
99
* - **OIDC Discovery**: Auto-discover endpoints from `/.well-known/openid-configuration` (RFC 8414)
1010
* - **Authorization Code Grant**: Automated browser-based login flow for testing
11+
* - **PKCE Support**: Automatic PKCE (S256) for enhanced security (RFC 7636)
12+
* - **Public Client Support**: Works with Public Clients (SPAs, Native Apps) - no client secret required
1113
* - **Token Management**: Automatic Authorization header injection after login
1214
* - **Type Safety**: Discriminated union for login params and results with type-safe error handling
1315
* - **Extensible**: Easy to add new grant types via discriminated union pattern
@@ -23,17 +25,16 @@
2325
* ```ts
2426
* import { createOidcHttpClient } from "@probitas/client-http/oidc";
2527
*
26-
* // Manual endpoint configuration
28+
* // OIDC Discovery (recommended)
2729
* const http = await createOidcHttpClient({
2830
* url: "http://localhost:3000",
2931
* oidc: {
30-
* authUrl: "/oauth/authorize",
31-
* tokenUrl: "/oauth/token",
32+
* issuer: "http://localhost:3000", // Auto-discover endpoints
3233
* clientId: "test-client",
3334
* },
3435
* });
3536
*
36-
* // Login with Authorization Code Grant
37+
* // Login with Authorization Code Grant (PKCE enabled automatically)
3738
* const result = await http.login({
3839
* type: "authorization_code",
3940
* username: "testuser",
@@ -94,10 +95,8 @@
9495
* await using http = await createOidcHttpClient({
9596
* url: "http://localhost:3000",
9697
* oidc: {
97-
* authUrl: "/oauth/authorize",
98-
* tokenUrl: "/oauth/token",
98+
* issuer: "http://localhost:3000",
9999
* clientId: "test-client",
100-
* clientSecret: "secret",
101100
* },
102101
* });
103102
*
@@ -113,6 +112,21 @@
113112
* // Automatic cleanup on scope exit
114113
* ```
115114
*
115+
* ## Manual Endpoint Configuration
116+
*
117+
* If your OIDC provider doesn't support Discovery, you can specify endpoints manually:
118+
*
119+
* ```ts
120+
* const http = await createOidcHttpClient({
121+
* url: "http://localhost:3000",
122+
* oidc: {
123+
* authUrl: "/oauth/authorize",
124+
* tokenUrl: "/oauth/token",
125+
* clientId: "test-client",
126+
* },
127+
* });
128+
* ```
129+
*
116130
* ## Related Packages
117131
*
118132
* | Package | Description |
@@ -143,7 +157,7 @@ import { createHttpClient } from "./client.ts";
143157
export interface OidcHttpClient extends HttpClient {
144158
/**
145159
* Perform OIDC login with specified grant type
146-
* Currently supports Authorization Code Grant with automatic form submission
160+
* Currently supports Authorization Code Grant with automatic form submission and PKCE (S256)
147161
*
148162
* @param params - Login parameters (discriminated union by type)
149163
* @returns Login result with token information
@@ -224,8 +238,6 @@ export interface OidcClientConfig {
224238
tokenUrl?: string;
225239
/** Client ID */
226240
clientId: string;
227-
/** Client secret (optional, for confidential clients) */
228-
clientSecret?: string;
229241
/** Redirect URI (default: "http://localhost/callback") */
230242
redirectUri?: string;
231243
/** OAuth scopes (default: "openid profile") */
@@ -360,8 +372,7 @@ async function fetchDiscoveryMetadata(
360372
* const http = await createOidcHttpClient({
361373
* url: "http://localhost:3000",
362374
* oidc: {
363-
* authUrl: "/oauth/authorize",
364-
* tokenUrl: "/oauth/token",
375+
* issuer: "http://localhost:3000",
365376
* clientId: "test-client",
366377
* },
367378
* });
@@ -542,24 +553,52 @@ export async function createOidcHttpClient(
542553
},
543554
};
544555

556+
// PKCE (Proof Key for Code Exchange) helpers (RFC 7636)
557+
function generateCodeVerifier(): string {
558+
// Generate 43-128 character random string using unreserved characters
559+
// Using base64url encoding of 32 random bytes = 43 characters
560+
const array = new Uint8Array(32);
561+
crypto.getRandomValues(array);
562+
return btoa(String.fromCharCode(...array))
563+
.replace(/\+/g, "-")
564+
.replace(/\//g, "_")
565+
.replace(/=/g, "");
566+
}
567+
568+
async function generateCodeChallenge(verifier: string): Promise<string> {
569+
// S256: BASE64URL(SHA256(ASCII(code_verifier)))
570+
const encoder = new TextEncoder();
571+
const data = encoder.encode(verifier);
572+
const hash = await crypto.subtle.digest("SHA-256", data);
573+
return btoa(String.fromCharCode(...new Uint8Array(hash)))
574+
.replace(/\+/g, "-")
575+
.replace(/\//g, "_")
576+
.replace(/=/g, "");
577+
}
578+
545579
// Authorization Code Grant implementation
546580
async function loginWithAuthorizationCodeGrant(
547581
username: string,
548582
password: string,
549583
options?: OidcAuthCodeOptions,
550584
): Promise<OidcLoginResult> {
551585
try {
552-
// Step 1: Generate state for CSRF protection (standard OIDC)
586+
// Step 1: Generate state and PKCE parameters
553587
const state = crypto.randomUUID();
588+
const codeVerifier = generateCodeVerifier();
589+
const codeChallenge = await generateCodeChallenge(codeVerifier);
554590

555591
// Get login form from authorization endpoint
556592
const loginFormUrl = options?.loginFormUrl || oidcConfig.authUrl;
557593
const loginFormResponse = await baseClient.get(loginFormUrl, {
558594
query: {
595+
client_id: oidcConfig.clientId,
559596
redirect_uri: oidcConfig.redirectUri,
560597
response_type: "code",
561598
scope: oidcConfig.scope,
562599
state, // Client-generated state
600+
code_challenge: codeChallenge, // PKCE
601+
code_challenge_method: "S256", // PKCE method
563602
},
564603
});
565604

@@ -657,12 +696,9 @@ export async function createOidcHttpClient(
657696
code,
658697
redirect_uri: oidcConfig.redirectUri,
659698
client_id: oidcConfig.clientId,
699+
code_verifier: codeVerifier, // PKCE
660700
});
661701

662-
if (oidcConfig.clientSecret) {
663-
tokenBody.set("client_secret", oidcConfig.clientSecret);
664-
}
665-
666702
const tokenResponse = await baseClient.post(oidcConfig.tokenUrl, {
667703
body: tokenBody,
668704
headers: {

0 commit comments

Comments
 (0)