Skip to content

Commit d7fa271

Browse files
committed
feat: added PKCE authentication clients with frontend and backend helpers
1 parent 4d66f5d commit d7fa271

File tree

8 files changed

+607
-13
lines changed

8 files changed

+607
-13
lines changed

sdk/.gitignore

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1,2 @@
1-
test.ts
1+
test.ts
2+
*.tgz

sdk/src/auth/backend.ts

Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
import { AuthConfig, TokenResponse, PKCETokens } from './types.js';
2+
import crypto from 'crypto';
3+
4+
/**
5+
* Backend authentication client for Stack Overflow Enterprise using PKCE flow
6+
* Designed for server-side Node.js environments with access to crypto module
7+
*/
8+
export class BackendAuthClient {
9+
private config: AuthConfig;
10+
11+
/**
12+
* Creates a new backend authentication client
13+
* @param config - Authentication configuration for Stack Overflow Enterprise
14+
*/
15+
constructor(config: AuthConfig) {
16+
this.config = config;
17+
}
18+
19+
/**
20+
* Generate PKCE tokens using Node.js crypto (backend)
21+
* Creates cryptographically secure code verifier, challenge, and state parameters
22+
*
23+
* @returns Promise resolving to PKCE tokens needed for secure OAuth flow
24+
* @example
25+
* ```typescript
26+
* const { codeVerifier, codeChallenge, state } = await authClient.generatePKCETokens();
27+
* // Store codeVerifier and state securely for later use
28+
* ```
29+
*/
30+
async generatePKCETokens(): Promise<PKCETokens> {
31+
// Use Node.js crypto for backend
32+
const codeVerifier = crypto.randomBytes(32).toString('hex');
33+
const codeChallenge = crypto
34+
.createHash('sha256')
35+
.update(codeVerifier)
36+
.digest('base64url');
37+
const state = crypto.randomBytes(16).toString('hex');
38+
39+
return {
40+
codeVerifier,
41+
codeChallenge,
42+
state,
43+
};
44+
}
45+
46+
/**
47+
* Generate authorization URL for Stack Overflow Enterprise with PKCE
48+
* Creates the URL where users should be redirected to authorize your application
49+
*
50+
* @returns Promise resolving to authorization data including the URL and tokens to store
51+
* @throws {Error} When clientId or redirectUri are missing from configuration
52+
* @example
53+
* ```typescript
54+
* const { url, codeVerifier, state } = await authClient.getAuthUrl();
55+
* // Redirect user to `url`
56+
* // Store `codeVerifier` and `state` securely (session, database, etc.)
57+
* ```
58+
*/
59+
async getAuthUrl(): Promise<{
60+
url: string;
61+
codeVerifier: string;
62+
state: string;
63+
}> {
64+
if (!this.config.clientId || !this.config.redirectUri) {
65+
throw new Error('clientId and redirectUri are required for authentication');
66+
}
67+
68+
const { codeVerifier, codeChallenge, state } = await this.generatePKCETokens();
69+
70+
// Stack Overflow Enterprise uses /oauth endpoint, not /oauth/authorize
71+
const authUrl = `${this.config.baseUrl}/oauth?client_id=${
72+
this.config.clientId
73+
}&redirect_uri=${encodeURIComponent(
74+
this.config.redirectUri,
75+
)}&code_challenge=${codeChallenge}&code_challenge_method=S256&state=${state}&scope=${
76+
this.config.scope || ''
77+
}`;
78+
79+
return { url: authUrl, codeVerifier, state };
80+
}
81+
82+
/**
83+
* Exchange authorization code for access token using PKCE
84+
* Completes the OAuth flow by trading the authorization code for an access token
85+
*
86+
* @param code - The authorization code received from the callback
87+
* @param codeVerifier - The code verifier that was generated during authorization URL creation
88+
* @returns Promise resolving to token response with access token and expiration
89+
* @throws {Error} When the token exchange fails or required config is missing
90+
* @example
91+
* ```typescript
92+
* // In your callback handler:
93+
* const tokens = await authClient.exchangeCodeForToken(
94+
* callbackCode,
95+
* storedCodeVerifier
96+
* );
97+
* console.log('Access token:', tokens.access_token);
98+
* console.log('Expires at:', new Date(tokens.expires * 1000));
99+
* ```
100+
*/
101+
async exchangeCodeForToken(
102+
code: string,
103+
codeVerifier: string,
104+
): Promise<TokenResponse> {
105+
if (!this.config.clientId || !this.config.redirectUri) {
106+
throw new Error('clientId and redirectUri are required for authentication');
107+
}
108+
109+
// Stack Overflow Enterprise uses /oauth/access_token/json endpoint
110+
const tokenUrl = `${this.config.baseUrl}/oauth/access_token/json`;
111+
112+
const queryParams = new URLSearchParams({
113+
client_id: String(this.config.clientId),
114+
code,
115+
redirect_uri: this.config.redirectUri,
116+
code_verifier: codeVerifier,
117+
});
118+
119+
const response = await fetch(`${tokenUrl}?${queryParams.toString()}`, {
120+
method: 'POST',
121+
headers: {
122+
'Content-Type': 'application/x-www-form-urlencoded',
123+
'Accept': 'application/json',
124+
},
125+
});
126+
127+
if (!response.ok) {
128+
const errorText = await response.text();
129+
throw new Error(`Failed to exchange code for access token: ${errorText}`);
130+
}
131+
132+
const data = await response.json();
133+
134+
return {
135+
access_token: data.access_token,
136+
expires: data.expires,
137+
};
138+
}
139+
140+
/**
141+
* Validate the state parameter for CSRF protection
142+
* Compares the state parameter received in the callback with the expected value
143+
*
144+
* @param receivedState - The state parameter received in the OAuth callback
145+
* @param expectedState - The state parameter that was originally generated
146+
* @returns True if the states match, false otherwise
147+
* @example
148+
* ```typescript
149+
* const isValid = authClient.validateState(callbackState, storedState);
150+
* if (!isValid) {
151+
* throw new Error('Possible CSRF attack detected');
152+
* }
153+
* ```
154+
*/
155+
validateState(receivedState: string, expectedState: string): boolean {
156+
return receivedState === expectedState;
157+
}
158+
}

sdk/src/auth/frontend.ts

Lines changed: 227 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,227 @@
1+
import { AuthConfig } from './types.js';
2+
3+
/**
4+
* Frontend authentication client for Stack Overflow Enterprise
5+
* This client communicates with your backend API to handle OAuth flows securely.
6+
* The backend performs all PKCE operations and token exchanges.
7+
*/
8+
export class FrontendAuthClient {
9+
private config: AuthConfig;
10+
private apiBaseUrl: string;
11+
12+
/**
13+
* Creates a new frontend authentication client
14+
* @param config - Authentication configuration for Stack Overflow Enterprise
15+
* @param apiBaseUrl - Base URL of your backend API that handles OAuth operations
16+
*/
17+
constructor(config: AuthConfig, apiBaseUrl: string = '/api') {
18+
this.config = config;
19+
this.apiBaseUrl = apiBaseUrl;
20+
}
21+
22+
/**
23+
* Start the authentication process by requesting an authorization URL from your backend
24+
* Your backend will generate PKCE tokens and return the authorization URL
25+
*
26+
* @returns Promise resolving to the authorization URL where users should be redirected
27+
* @throws {Error} When the backend request fails
28+
* @example
29+
* ```typescript
30+
* const authUrl = await frontendClient.startAuth();
31+
* window.location.href = authUrl; // Redirect user to Stack Overflow Enterprise
32+
* ```
33+
*/
34+
async startAuth(): Promise<string> {
35+
try {
36+
const response = await this.requestAPI<{ authUrl: string }>('auth/start');
37+
return response.authUrl;
38+
} catch (error) {
39+
throw new Error(`Failed to start authentication: ${error}`);
40+
}
41+
}
42+
43+
/**
44+
* Complete the authentication process by sending the callback parameters to your backend
45+
* Your backend will validate the state, exchange the code for tokens, and store them securely
46+
*
47+
* @param code - The authorization code received from Stack Overflow Enterprise callback
48+
* @param state - The state parameter received from Stack Overflow Enterprise callback
49+
* @throws {Error} When the backend request fails or authentication is invalid
50+
* @example
51+
* ```typescript
52+
* // In your callback page/component:
53+
* const urlParams = new URLSearchParams(window.location.search);
54+
* const code = urlParams.get('code');
55+
* const state = urlParams.get('state');
56+
*
57+
* if (code && state) {
58+
* await frontendClient.completeAuth(code, state);
59+
* // User is now authenticated, redirect to main app
60+
* }
61+
* ```
62+
*/
63+
async completeAuth(code: string, state: string): Promise<void> {
64+
try {
65+
await this.requestAPI('callback', 'GET', undefined, [
66+
`code=${encodeURIComponent(code)}`,
67+
`state=${encodeURIComponent(state)}`,
68+
]);
69+
} catch (error) {
70+
throw new Error(`Failed to complete authentication: ${error}`);
71+
}
72+
}
73+
74+
/**
75+
* Check if the user is currently authenticated
76+
* Verifies with your backend whether valid authentication exists (e.g., via HTTP-only cookies)
77+
*
78+
* @returns Promise resolving to true if authenticated, false otherwise
79+
* @example
80+
* ```typescript
81+
* const isAuthenticated = await frontendClient.getAuthStatus();
82+
* if (!isAuthenticated) {
83+
* // Redirect to login
84+
* }
85+
* ```
86+
*/
87+
async getAuthStatus(): Promise<boolean> {
88+
try {
89+
await this.requestAPI('authStatus');
90+
return true;
91+
} catch {
92+
return false;
93+
}
94+
}
95+
96+
/**
97+
* Log out the current user
98+
* Clears authentication state on your backend (e.g., removes HTTP-only cookies, clears tokens)
99+
*
100+
* @returns Promise resolving to true if logout was successful, false otherwise
101+
* @example
102+
* ```typescript
103+
* const loggedOut = await frontendClient.logout();
104+
* if (loggedOut) {
105+
* // Redirect to login page
106+
* }
107+
* ```
108+
*/
109+
async logout(): Promise<boolean> {
110+
try {
111+
await this.requestAPI('logout', 'POST');
112+
return true;
113+
} catch {
114+
return false;
115+
}
116+
}
117+
118+
/**
119+
* Submit an access token directly (for cases where user provides their own token)
120+
* Allows users to manually provide a Stack Overflow Enterprise access token
121+
*
122+
* @param token - The Stack Overflow Enterprise access token
123+
* @returns Promise resolving to true if token was accepted, false otherwise
124+
* @example
125+
* ```typescript
126+
* // If user has a personal access token:
127+
* const success = await frontendClient.submitAccessToken(userProvidedToken);
128+
* if (success) {
129+
* // Token stored successfully
130+
* }
131+
* ```
132+
*/
133+
async submitAccessToken(token: string): Promise<boolean> {
134+
try {
135+
await this.requestAPI('auth/token', 'POST', { accessToken: token });
136+
return true;
137+
} catch {
138+
return false;
139+
}
140+
}
141+
142+
/**
143+
* Handle the OAuth callback in the current page
144+
* Parses the current URL for OAuth callback parameters and completes authentication
145+
*
146+
* @returns Promise resolving when authentication is complete
147+
* @throws {Error} When callback parameters are missing or authentication fails
148+
* @example
149+
* ```typescript
150+
* // In your OAuth callback page:
151+
* try {
152+
* await frontendClient.handleCallback();
153+
* // Success - user is authenticated
154+
* window.location.href = '/dashboard';
155+
* } catch (error) {
156+
* // Handle error
157+
* console.error('Authentication failed:', error);
158+
* }
159+
* ```
160+
*/
161+
async handleCallback(): Promise<void> {
162+
const url = new URL(window.location.href);
163+
const code = url.searchParams.get('code');
164+
const state = url.searchParams.get('state');
165+
const error = url.searchParams.get('error');
166+
167+
if (error) {
168+
throw new Error(`OAuth error: ${error}`);
169+
}
170+
171+
if (!code || !state) {
172+
throw new Error('Authorization code or state not found in callback URL');
173+
}
174+
175+
await this.completeAuth(code, state);
176+
}
177+
178+
/**
179+
* Make a request to your backend API
180+
* Internal utility method for communicating with your authentication backend
181+
*
182+
* @param endpoint - API endpoint path
183+
* @param method - HTTP method (defaults to 'GET')
184+
* @param body - Request body for POST/PUT requests
185+
* @param queryParams - Query parameters to append to the URL
186+
* @returns Promise resolving to the API response
187+
* @private
188+
*/
189+
private async requestAPI<T = any>(
190+
endpoint: string,
191+
method: string = 'GET',
192+
body?: any,
193+
queryParams?: string[]
194+
): Promise<T> {
195+
const url = new URL(`${this.apiBaseUrl}/${endpoint}`, window.location.origin);
196+
197+
if (queryParams) {
198+
url.search = queryParams.join('&');
199+
}
200+
201+
const options: RequestInit = {
202+
method,
203+
headers: {
204+
'Content-Type': 'application/json',
205+
},
206+
credentials: 'include', // Important for HTTP-only cookies
207+
};
208+
209+
if (body && (method === 'POST' || method === 'PUT')) {
210+
options.body = JSON.stringify(body);
211+
}
212+
213+
const response = await fetch(url.toString(), options);
214+
215+
if (!response.ok) {
216+
const errorText = await response.text();
217+
throw new Error(`API request failed: ${response.status} ${errorText}`);
218+
}
219+
220+
// Return empty object for successful requests with no content
221+
if (response.status === 204 || response.headers.get('content-length') === '0') {
222+
return {} as T;
223+
}
224+
225+
return response.json();
226+
}
227+
}

sdk/src/auth/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
export { BackendAuthClient } from './backend.js';
2+
export { FrontendAuthClient } from './frontend.js';
3+
export type { AuthConfig, TokenResponse, PKCETokens } from './types.js';

0 commit comments

Comments
 (0)