@apiquest/plugin-auth 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of @apiquest/plugin-auth might be problematic. Click here for more details.

@@ -0,0 +1,339 @@
1
+ // OAuth 2.0 Authentication
2
+ import got from 'got';
3
+ import type { IAuthPlugin, Request, Auth, RuntimeOptions, ValidationResult, ILogger } from '@apiquest/types';
4
+ import { isNullOrEmpty, isNullOrWhitespace } from './helpers.js';
5
+
6
+ /**
7
+ * OAuth2 configuration interface
8
+ *
9
+ * Client credential placement:
10
+ * - clientId/clientSecret: Always required, contain the actual credential values
11
+ * - clientAuthentication: Where/how to send credentials to token endpoint
12
+ * - 'body': Send as client_id/client_secret in form body (default, standard OAuth2)
13
+ * - 'basic': Send as HTTP Basic Auth header (Base64 encoded clientId:clientSecret)
14
+ * - 'header': Send as custom headers (names specified by clientIdField/clientSecretField)
15
+ * - 'query': Send as query parameters (names specified by clientIdField/clientSecretField)
16
+ * - clientIdField/clientSecretField: Custom field names for 'header' or 'query' placement
17
+ * - extraHeaders/extraBody/extraQuery: Additional parameters for token request
18
+ * - cacheToken: Whether to cache token across requests (default: true)
19
+ */
20
+ export interface OAuth2Config {
21
+ grantType: 'client_credentials' | 'password' | 'authorization_code';
22
+ accessTokenUrl: string;
23
+ clientId: string;
24
+ clientSecret: string;
25
+ username?: string;
26
+ password?: string;
27
+ scope?: string;
28
+ authorizationCode?: string;
29
+ redirectUri?: string;
30
+ // Client credential placement (default: 'body' for backwards compatibility)
31
+ clientAuthentication?: 'body' | 'basic' | 'header' | 'query';
32
+ clientIdField?: string; // Field name for header/query placement (default: 'client_id')
33
+ clientSecretField?: string; // Field name for header/query placement (default: 'client_secret')
34
+ // Extra parameters to include in token request
35
+ extraHeaders?: Record<string, string>;
36
+ extraBody?: Record<string, unknown>;
37
+ extraQuery?: Record<string, string>;
38
+ // Token caching (default: true for performance)
39
+ cacheToken?: boolean;
40
+ }
41
+
42
+ // Simple in-memory token cache
43
+ const tokenCache = new Map<string, { token: string; expiresAt: number }>();
44
+
45
+ // Helper function for OAuth2 token retrieval
46
+ async function getOAuth2AccessToken(config: OAuth2Config, logger?: ILogger): Promise<string> {
47
+ logger?.trace('Token request initiated');
48
+ logger?.trace('Grant type', { grantType: config.grantType });
49
+ logger?.trace('Token endpoint', { url: config.accessTokenUrl });
50
+ logger?.trace('Client auth method', { method: config.clientAuthentication ?? 'body' });
51
+ const cachingEnabled = (config.cacheToken ?? true);
52
+ logger?.trace('Token caching', { enabled: cachingEnabled });
53
+
54
+ const cacheKey = `${config.accessTokenUrl}:${config.clientId}:${config.grantType}`;
55
+
56
+ const useCaching = (config.cacheToken ?? true);
57
+ if (useCaching) {
58
+ const cached = tokenCache.get(cacheKey);
59
+ if (cached !== undefined && cached.expiresAt > Date.now()) {
60
+ logger?.trace('Using cached token', { expiresInSeconds: Math.floor((cached.expiresAt - Date.now()) / 1000) });
61
+ return cached.token;
62
+ } else if (cached !== undefined) {
63
+ logger?.trace('Cached token expired, fetching new token');
64
+ }
65
+ } else {
66
+ logger?.trace('Token caching disabled, fetching new token');
67
+ }
68
+
69
+ const clientAuth = config.clientAuthentication ?? 'body';
70
+
71
+ // Build request parameters
72
+ const params = new URLSearchParams();
73
+ params.append('grant_type', config.grantType);
74
+
75
+ // Add client credentials based on authentication method
76
+ if (clientAuth === 'body') {
77
+ params.append('client_id', config.clientId);
78
+ params.append('client_secret', config.clientSecret);
79
+ }
80
+
81
+ // Add scope if specified
82
+ if (!isNullOrWhitespace(config.scope) && config.scope !== undefined) {
83
+ params.append('scope', config.scope);
84
+ }
85
+
86
+ // Grant type specific params
87
+ if (config.grantType === 'password' && !isNullOrEmpty(config.username) && !isNullOrEmpty(config.password)) {
88
+ params.append('username', config.username ?? '');
89
+ params.append('password', config.password ?? '');
90
+ } else if (config.grantType === 'authorization_code' && !isNullOrEmpty(config.authorizationCode)) {
91
+ params.append('code', config.authorizationCode ?? '');
92
+ if (!isNullOrEmpty(config.redirectUri)) {
93
+ params.append('redirect_uri', config.redirectUri ?? '');
94
+ }
95
+ }
96
+
97
+ // Add extra body parameters
98
+ if (config.extraBody !== null && config.extraBody !== undefined) {
99
+ for (const [key, value] of Object.entries(config.extraBody)) {
100
+ params.append(key, String(value));
101
+ }
102
+ }
103
+
104
+ // Build headers
105
+ const headers: Record<string, string> = {
106
+ 'Content-Type': 'application/x-www-form-urlencoded'
107
+ };
108
+
109
+ // Add client credentials to headers if needed
110
+ if (clientAuth === 'basic') {
111
+ const credentials = Buffer.from(`${config.clientId}:${config.clientSecret}`).toString('base64');
112
+ headers['Authorization'] = `Basic ${credentials}`;
113
+ } else if (clientAuth === 'header') {
114
+ const idField = config.clientIdField ?? 'X-Client-Id';
115
+ const secretField = config.clientSecretField ?? 'X-Client-Secret';
116
+ headers[idField] = config.clientId;
117
+ headers[secretField] = config.clientSecret;
118
+ }
119
+
120
+ // Add extra headers
121
+ if (config.extraHeaders !== null && config.extraHeaders !== undefined) {
122
+ Object.assign(headers, config.extraHeaders);
123
+ }
124
+
125
+ // Build URL with query parameters if needed
126
+ let tokenUrl = config.accessTokenUrl;
127
+ if (clientAuth === 'query') {
128
+ const url = new URL(config.accessTokenUrl);
129
+ const idField = config.clientIdField ?? 'client_id';
130
+ const secretField = config.clientSecretField ?? 'client_secret';
131
+ url.searchParams.set(idField, config.clientId);
132
+ url.searchParams.set(secretField, config.clientSecret);
133
+
134
+ // Add extra query parameters
135
+ if (config.extraQuery !== null && config.extraQuery !== undefined) {
136
+ for (const [key, value] of Object.entries(config.extraQuery)) {
137
+ url.searchParams.set(key, value);
138
+ }
139
+ }
140
+
141
+ tokenUrl = url.toString();
142
+ }
143
+
144
+ logger?.trace('Token request details', { url: tokenUrl, headers: JSON.stringify(headers), body: params.toString() });
145
+
146
+ try {
147
+ const response = await got.post(tokenUrl, {
148
+ body: params.toString(),
149
+ headers,
150
+ responseType: 'json'
151
+ });
152
+
153
+ interface OAuth2TokenResponse {
154
+ access_token?: string;
155
+ expires_in?: number;
156
+ [key: string]: unknown;
157
+ }
158
+
159
+ const data = response.body as OAuth2TokenResponse;
160
+ const accessToken = data.access_token;
161
+ const expiresIn = (typeof data.expires_in === 'number' && data.expires_in > 0) ? data.expires_in : 3600;
162
+
163
+ logger?.trace('Token retrieved', { expiresInSeconds: expiresIn });
164
+
165
+ if (accessToken === undefined) {
166
+ throw new Error('No access_token in OAuth2 response');
167
+ }
168
+
169
+ if (useCaching) {
170
+ const expiresAt = Date.now() + (expiresIn - 300) * 1000;
171
+ tokenCache.set(cacheKey, { token: accessToken, expiresAt });
172
+ logger?.trace('Token cached', { expiresAt: new Date(expiresAt).toISOString() });
173
+ }
174
+
175
+ return accessToken;
176
+ } catch (error) {
177
+ interface ErrorWithResponse {
178
+ message: string;
179
+ response?: {
180
+ statusCode: number;
181
+ body: unknown;
182
+ };
183
+ }
184
+
185
+ const err = error as ErrorWithResponse;
186
+ const details = [];
187
+ details.push(`OAuth2 token request failed: ${err.message}`);
188
+ if (err.response !== undefined) {
189
+ details.push(` Status: ${err.response.statusCode}`);
190
+ details.push(` Response: ${JSON.stringify(err.response.body)}`);
191
+ }
192
+ details.push(` URL: ${tokenUrl}`);
193
+ details.push(` Method: ${clientAuth}`);
194
+ details.push(` Body: ${params.toString()}`);
195
+
196
+ const fullError = details.join('\n');
197
+ logger?.debug('OAuth2 token request failed', { details: fullError });
198
+ throw new Error(fullError);
199
+ }
200
+ }
201
+
202
+ export const oauth2Auth: IAuthPlugin = {
203
+ // Identity
204
+ name: 'OAuth 2.0',
205
+ version: '1.0.0',
206
+ description: 'OAuth 2.0 authentication (multiple grant types supported)',
207
+
208
+ // What auth types this provides
209
+ authTypes: ['oauth2'],
210
+
211
+ // Which protocols this works with
212
+ protocols: ['http', 'graphql', 'grpc'],
213
+
214
+ dataSchema: {
215
+ type: 'object',
216
+ required: ['grantType', 'accessTokenUrl', 'clientId', 'clientSecret'],
217
+ properties: {
218
+ grantType: {
219
+ type: 'string',
220
+ enum: ['client_credentials', 'password', 'authorization_code'],
221
+ description: 'OAuth 2.0 grant type'
222
+ },
223
+ accessTokenUrl: {
224
+ type: 'string',
225
+ description: 'Token endpoint URL'
226
+ },
227
+ clientId: {
228
+ type: 'string',
229
+ description: 'OAuth client ID (always required, contains the credential value)'
230
+ },
231
+ clientSecret: {
232
+ type: 'string',
233
+ description: 'OAuth client secret (always required, contains the credential value)'
234
+ },
235
+ username: {
236
+ type: 'string',
237
+ description: 'Username (for password grant)'
238
+ },
239
+ password: {
240
+ type: 'string',
241
+ description: 'Password (for password grant)'
242
+ },
243
+ scope: {
244
+ type: 'string',
245
+ description: 'OAuth scope'
246
+ },
247
+ authorizationCode: {
248
+ type: 'string',
249
+ description: 'Authorization code (for authorization_code grant)'
250
+ },
251
+ redirectUri: {
252
+ type: 'string',
253
+ description: 'Redirect URI'
254
+ },
255
+ clientAuthentication: {
256
+ type: 'string',
257
+ enum: ['body', 'basic', 'header', 'query'],
258
+ default: 'body',
259
+ description: 'How to send client credentials: body (form), basic (HTTP Basic Auth), header (custom headers), query (URL params)'
260
+ },
261
+ clientIdField: {
262
+ type: 'string',
263
+ description: 'Field name for client ID when using header or query authentication (default: client_id for query, X-Client-Id for header)'
264
+ },
265
+ clientSecretField: {
266
+ type: 'string',
267
+ description: 'Field name for client secret when using header or query authentication (default: client_secret for query, X-Client-Secret for header)'
268
+ },
269
+ extraHeaders: {
270
+ type: 'object',
271
+ description: 'Additional headers to include in token request (e.g., {"X-Trace-Id": "trace-123"})'
272
+ },
273
+ extraBody: {
274
+ type: 'object',
275
+ description: 'Additional form body parameters to include in token request (e.g., {"audience": "api"})'
276
+ },
277
+ extraQuery: {
278
+ type: 'object',
279
+ description: 'Additional query parameters to include in token request (e.g., {"audience": "api"})'
280
+ },
281
+ cacheToken: {
282
+ type: 'boolean',
283
+ default: true,
284
+ description: 'Whether to cache tokens across requests (default: true). Set to false for deterministic/stateless execution.'
285
+ }
286
+ }
287
+ },
288
+
289
+ validate(auth: Auth, options: RuntimeOptions): ValidationResult {
290
+ const errors = [];
291
+ const config = auth.data as OAuth2Config | null | undefined;
292
+
293
+ if (config === null || config === undefined || isNullOrWhitespace(config.accessTokenUrl)) {
294
+ errors.push({
295
+ message: 'OAuth2 accessTokenUrl is required',
296
+ location: '',
297
+ source: 'auth' as const
298
+ });
299
+ }
300
+ if (config === null || config === undefined || isNullOrWhitespace(config.clientId)) {
301
+ errors.push({
302
+ message: 'OAuth2 clientId is required',
303
+ location: '',
304
+ source: 'auth' as const
305
+ });
306
+ }
307
+ if (config === null || config === undefined || isNullOrWhitespace(config.clientSecret)) {
308
+ errors.push({
309
+ message: 'OAuth2 clientSecret is required',
310
+ location: '',
311
+ source: 'auth' as const
312
+ });
313
+ }
314
+
315
+ return errors.length > 0 ? { valid: false, errors } : { valid: true };
316
+ },
317
+
318
+ async apply(request: Request, auth: Auth, options: RuntimeOptions, logger?: ILogger): Promise<Request> {
319
+ if ((request.data.headers as Record<string, unknown> | null | undefined)?.['Authorization'] !== undefined) {
320
+ logger?.trace('Authorization header already present, skipping OAuth2 apply');
321
+ return request;
322
+ }
323
+
324
+ const config = auth.data as unknown as OAuth2Config;
325
+
326
+ if (isNullOrEmpty(config.accessTokenUrl) || isNullOrEmpty(config.clientId) || isNullOrEmpty(config.clientSecret)) {
327
+ logger?.error('OAuth2 config missing accessTokenUrl, clientId, or clientSecret');
328
+ throw new Error('OAuth2 requires accessTokenUrl, clientId, and clientSecret');
329
+ }
330
+
331
+ const token = await getOAuth2AccessToken(config, logger);
332
+
333
+ request.data.headers ??= {};
334
+ (request.data.headers as Record<string, string>)['Authorization'] = `Bearer ${token}`;
335
+ logger?.debug('OAuth2 Authorization header applied');
336
+
337
+ return request;
338
+ }
339
+ };
package/tsconfig.json ADDED
@@ -0,0 +1,19 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2020",
4
+ "module": "ESNext",
5
+ "lib": ["ES2020"],
6
+ "outDir": "./dist",
7
+ "declaration": true,
8
+ "declarationMap": true,
9
+ "sourceMap": true,
10
+ "strict": true,
11
+ "esModuleInterop": true,
12
+ "skipLibCheck": true,
13
+ "forceConsistentCasingInFileNames": true,
14
+ "resolveJsonModule": true,
15
+ "moduleResolution": "bundler"
16
+ },
17
+ "include": ["src/**/*"],
18
+ "exclude": ["node_modules", "dist", "tests"]
19
+ }
@@ -0,0 +1,5 @@
1
+ {
2
+ "extends": "./tsconfig.json",
3
+ "include": ["tests/**/*"],
4
+ "exclude": []
5
+ }
@@ -0,0 +1,12 @@
1
+ import { defineConfig } from 'vitest/config';
2
+
3
+ export default defineConfig({
4
+ test: {
5
+ globals: true,
6
+ environment: 'node',
7
+ coverage: {
8
+ provider: 'v8',
9
+ reporter: ['text', 'json', 'html'],
10
+ },
11
+ },
12
+ });