|
8 | 8 | * |
9 | 9 | * - **OIDC Discovery**: Auto-discover endpoints from `/.well-known/openid-configuration` (RFC 8414) |
10 | 10 | * - **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 |
11 | 13 | * - **Token Management**: Automatic Authorization header injection after login |
12 | 14 | * - **Type Safety**: Discriminated union for login params and results with type-safe error handling |
13 | 15 | * - **Extensible**: Easy to add new grant types via discriminated union pattern |
|
23 | 25 | * ```ts |
24 | 26 | * import { createOidcHttpClient } from "@probitas/client-http/oidc"; |
25 | 27 | * |
26 | | - * // Manual endpoint configuration |
| 28 | + * // OIDC Discovery (recommended) |
27 | 29 | * const http = await createOidcHttpClient({ |
28 | 30 | * url: "http://localhost:3000", |
29 | 31 | * oidc: { |
30 | | - * authUrl: "/oauth/authorize", |
31 | | - * tokenUrl: "/oauth/token", |
| 32 | + * issuer: "http://localhost:3000", // Auto-discover endpoints |
32 | 33 | * clientId: "test-client", |
33 | 34 | * }, |
34 | 35 | * }); |
35 | 36 | * |
36 | | - * // Login with Authorization Code Grant |
| 37 | + * // Login with Authorization Code Grant (PKCE enabled automatically) |
37 | 38 | * const result = await http.login({ |
38 | 39 | * type: "authorization_code", |
39 | 40 | * username: "testuser", |
|
94 | 95 | * await using http = await createOidcHttpClient({ |
95 | 96 | * url: "http://localhost:3000", |
96 | 97 | * oidc: { |
97 | | - * authUrl: "/oauth/authorize", |
98 | | - * tokenUrl: "/oauth/token", |
| 98 | + * issuer: "http://localhost:3000", |
99 | 99 | * clientId: "test-client", |
100 | | - * clientSecret: "secret", |
101 | 100 | * }, |
102 | 101 | * }); |
103 | 102 | * |
|
113 | 112 | * // Automatic cleanup on scope exit |
114 | 113 | * ``` |
115 | 114 | * |
| 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 | + * |
116 | 130 | * ## Related Packages |
117 | 131 | * |
118 | 132 | * | Package | Description | |
@@ -143,7 +157,7 @@ import { createHttpClient } from "./client.ts"; |
143 | 157 | export interface OidcHttpClient extends HttpClient { |
144 | 158 | /** |
145 | 159 | * 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) |
147 | 161 | * |
148 | 162 | * @param params - Login parameters (discriminated union by type) |
149 | 163 | * @returns Login result with token information |
@@ -224,8 +238,6 @@ export interface OidcClientConfig { |
224 | 238 | tokenUrl?: string; |
225 | 239 | /** Client ID */ |
226 | 240 | clientId: string; |
227 | | - /** Client secret (optional, for confidential clients) */ |
228 | | - clientSecret?: string; |
229 | 241 | /** Redirect URI (default: "http://localhost/callback") */ |
230 | 242 | redirectUri?: string; |
231 | 243 | /** OAuth scopes (default: "openid profile") */ |
@@ -360,8 +372,7 @@ async function fetchDiscoveryMetadata( |
360 | 372 | * const http = await createOidcHttpClient({ |
361 | 373 | * url: "http://localhost:3000", |
362 | 374 | * oidc: { |
363 | | - * authUrl: "/oauth/authorize", |
364 | | - * tokenUrl: "/oauth/token", |
| 375 | + * issuer: "http://localhost:3000", |
365 | 376 | * clientId: "test-client", |
366 | 377 | * }, |
367 | 378 | * }); |
@@ -542,24 +553,52 @@ export async function createOidcHttpClient( |
542 | 553 | }, |
543 | 554 | }; |
544 | 555 |
|
| 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 | + |
545 | 579 | // Authorization Code Grant implementation |
546 | 580 | async function loginWithAuthorizationCodeGrant( |
547 | 581 | username: string, |
548 | 582 | password: string, |
549 | 583 | options?: OidcAuthCodeOptions, |
550 | 584 | ): Promise<OidcLoginResult> { |
551 | 585 | try { |
552 | | - // Step 1: Generate state for CSRF protection (standard OIDC) |
| 586 | + // Step 1: Generate state and PKCE parameters |
553 | 587 | const state = crypto.randomUUID(); |
| 588 | + const codeVerifier = generateCodeVerifier(); |
| 589 | + const codeChallenge = await generateCodeChallenge(codeVerifier); |
554 | 590 |
|
555 | 591 | // Get login form from authorization endpoint |
556 | 592 | const loginFormUrl = options?.loginFormUrl || oidcConfig.authUrl; |
557 | 593 | const loginFormResponse = await baseClient.get(loginFormUrl, { |
558 | 594 | query: { |
| 595 | + client_id: oidcConfig.clientId, |
559 | 596 | redirect_uri: oidcConfig.redirectUri, |
560 | 597 | response_type: "code", |
561 | 598 | scope: oidcConfig.scope, |
562 | 599 | state, // Client-generated state |
| 600 | + code_challenge: codeChallenge, // PKCE |
| 601 | + code_challenge_method: "S256", // PKCE method |
563 | 602 | }, |
564 | 603 | }); |
565 | 604 |
|
@@ -657,12 +696,9 @@ export async function createOidcHttpClient( |
657 | 696 | code, |
658 | 697 | redirect_uri: oidcConfig.redirectUri, |
659 | 698 | client_id: oidcConfig.clientId, |
| 699 | + code_verifier: codeVerifier, // PKCE |
660 | 700 | }); |
661 | 701 |
|
662 | | - if (oidcConfig.clientSecret) { |
663 | | - tokenBody.set("client_secret", oidcConfig.clientSecret); |
664 | | - } |
665 | | - |
666 | 702 | const tokenResponse = await baseClient.post(oidcConfig.tokenUrl, { |
667 | 703 | body: tokenBody, |
668 | 704 | headers: { |
|
0 commit comments