@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.
- package/LICENSE.txt +661 -0
- package/dist/apikey-auth.d.ts +3 -0
- package/dist/apikey-auth.d.ts.map +1 -0
- package/dist/basic-auth.d.ts +3 -0
- package/dist/basic-auth.d.ts.map +1 -0
- package/dist/bearer-auth.d.ts +3 -0
- package/dist/bearer-auth.d.ts.map +1 -0
- package/dist/helpers.d.ts +3 -0
- package/dist/helpers.d.ts.map +1 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +12676 -0
- package/dist/index.js.map +1 -0
- package/dist/oauth2-auth.d.ts +35 -0
- package/dist/oauth2-auth.d.ts.map +1 -0
- package/dist/src/apikey-auth.d.ts +3 -0
- package/dist/src/apikey-auth.d.ts.map +1 -0
- package/dist/src/basic-auth.d.ts +3 -0
- package/dist/src/basic-auth.d.ts.map +1 -0
- package/dist/src/bearer-auth.d.ts +3 -0
- package/dist/src/bearer-auth.d.ts.map +1 -0
- package/dist/src/helpers.d.ts +3 -0
- package/dist/src/helpers.d.ts.map +1 -0
- package/dist/src/index.d.ts +10 -0
- package/dist/src/index.d.ts.map +1 -0
- package/dist/src/oauth2-auth.d.ts +35 -0
- package/dist/src/oauth2-auth.d.ts.map +1 -0
- package/esbuild.config.js +16 -0
- package/package.json +61 -0
- package/rollup.config.js +31 -0
- package/src/apikey-auth.ts +79 -0
- package/src/basic-auth.ts +65 -0
- package/src/bearer-auth.ts +54 -0
- package/src/helpers.ts +8 -0
- package/src/index.ts +32 -0
- package/src/oauth2-auth.ts +339 -0
- package/tsconfig.json +19 -0
- package/tsconfig.test.json +5 -0
- package/vitest.config.ts +12 -0
|
@@ -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
|
+
}
|