1import { Actor } from 'apify';
2import dns from 'node:dns/promises';
3import net from 'node:net';
4import { fileURLToPath } from 'node:url';
5
6const USER_AGENT = 'OAuthAuthorizationServerAuditor/0.1 (+https://apify.com)';
7const DEFAULT_TIMEOUT_SECONDS = 10;
8const MAX_BODY_BYTES = 2 * 1024 * 1024;
9
10
11
12
13
14
15
16
17function isPrivateIPv4(ip) {
18 const parts = ip.split('.').map(Number);
19 if (parts.length !== 4 || parts.some((n) => Number.isNaN(n))) return false;
20 const [a, b] = parts;
21 return a === 10
22 || (a === 172 && b >= 16 && b <= 31)
23 || (a === 192 && b === 168)
24 || a === 127
25 || a === 0
26 || (a === 169 && b === 254);
27}
28
29function isPrivateIPv6(ip) {
30 const normalized = ip.toLowerCase();
31 return normalized === '::1'
32 || normalized.startsWith('fc')
33 || normalized.startsWith('fd')
34 || normalized.startsWith('fe80:');
35}
36
37export async function normalizeAndValidateUrl(rawUrl) {
38 if (!rawUrl || typeof rawUrl !== 'string') throw new Error('issuer URL is required');
39 if (/^[a-z][a-z0-9+.-]*:/i.test(rawUrl) && !/^https?:\/\//i.test(rawUrl)) {
40 throw new Error('Only HTTP and HTTPS URLs are supported');
41 }
42
43 const withScheme = /^https?:\/\//i.test(rawUrl) ? rawUrl : `https://${rawUrl}`;
44 const url = new URL(withScheme);
45 if (!['http:', 'https:'].includes(url.protocol)) throw new Error('Only HTTP and HTTPS URLs are supported');
46 if (!url.hostname || url.username || url.password) throw new Error('URL must be public and must not include credentials');
47
48 const hostname = url.hostname.replace(/^\[|\]$/g, '');
49 const literalType = net.isIP(hostname);
50 if (literalType === 4 && isPrivateIPv4(hostname)) throw new Error('Private IPv4 targets are blocked');
51 if (literalType === 6 && isPrivateIPv6(hostname)) throw new Error('Private IPv6 targets are blocked');
52
53 const records = literalType ? [{ address: hostname, family: literalType }] : await dns.lookup(url.hostname, { all: true });
54 for (const record of records) {
55 if (record.family === 4 && isPrivateIPv4(record.address)) throw new Error('DNS resolves to a private IPv4 address; blocked for SSRF safety');
56 if (record.family === 6 && isPrivateIPv6(record.address)) throw new Error('DNS resolves to a private IPv6 address; blocked for SSRF safety');
57 }
58 return url;
59}
60
61
62
63
64
65
66
67async function fetchJson(initialUrl, timeoutSeconds, redirectsRemaining = 3) {
68 await normalizeAndValidateUrl(initialUrl.href);
69 const controller = new AbortController();
70 const timeout = setTimeout(() => controller.abort(), timeoutSeconds * 1000);
71 try {
72 const response = await fetch(initialUrl, {
73 method: 'GET',
74 redirect: 'manual',
75 signal: controller.signal,
76 headers: {
77 'user-agent': USER_AGENT,
78 accept: 'application/json, application/json;charset=UTF-8, */*;q=0.1',
79 },
80 });
81
82 if ([301, 302, 303, 307, 308].includes(response.status)) {
83 if (redirectsRemaining <= 0) throw new Error('Too many redirects');
84 const location = response.headers.get('location');
85 if (!location) throw new Error('Redirect without Location header');
86 const nextUrl = new URL(location, initialUrl.href);
87 await normalizeAndValidateUrl(nextUrl.href);
88 return fetchJson(nextUrl, timeoutSeconds, redirectsRemaining - 1);
89 }
90
91
92 let text = '';
93 try {
94 const buffer = await response.arrayBuffer();
95 if (buffer.byteLength > MAX_BODY_BYTES) {
96 text = new TextDecoder().decode(buffer.slice(0, MAX_BODY_BYTES));
97 } else {
98 text = new TextDecoder().decode(buffer);
99 }
100 } catch {
101 text = '';
102 }
103
104 return {
105 ok: response.ok,
106 status: response.status,
107 finalUrl: response.url || initialUrl.href,
108 contentType: response.headers.get('content-type') || '',
109 https: (response.url || initialUrl.href).startsWith('https://'),
110 body: text,
111 error: null,
112 };
113 } catch (error) {
114 return {
115 ok: false,
116 status: null,
117 finalUrl: initialUrl.href,
118 contentType: '',
119 https: initialUrl.protocol === 'https:',
120 body: '',
121 error: error.message,
122 };
123 } finally {
124 clearTimeout(timeout);
125 }
126}
127
128
129
130
131
132
133
134const RECOMMENDED_KEYS = [
135 'jwks_uri',
136 'grant_types_supported',
137 'token_endpoint_auth_methods_supported',
138 'token_endpoint_auth_signing_alg_values_supported',
139 'revocation_endpoint',
140 'revocation_endpoint_auth_methods_supported',
141 'introspection_endpoint',
142 'introspection_endpoint_auth_methods_supported',
143 'code_challenge_methods_supported',
144 'service_documentation',
145 'ui_locales_supported',
146 'op_policy_uri',
147 'op_tos_uri',
148 'scopes_supported',
149];
150
151const ENDPOINT_KEYS = [
152 'authorization_endpoint',
153 'token_endpoint',
154 'jwks_uri',
155 'registration_endpoint',
156 'revocation_endpoint',
157 'introspection_endpoint',
158];
159
160
161const GRANTS_USING_AUTHORIZATION_ENDPOINT = ['authorization_code', 'implicit'];
162
163const GRANTS_USING_TOKEN_ENDPOINT = [
164 'authorization_code',
165 'client_credentials',
166 'refresh_token',
167 'password',
168 'urn:ietf:params:oauth:grant-type:device_code',
169 'urn:ietf:params:oauth:grant-type:jwt-bearer',
170 'urn:ietf:params:oauth:grant-type:saml2-bearer',
171];
172
173function grantTypesOf(doc) {
174 return Array.isArray(doc?.grant_types_supported) ? doc.grant_types_supported : null;
175}
176
177
178
179
180
181export function requiresAuthorizationEndpoint(doc) {
182 const grants = grantTypesOf(doc);
183 if (!grants) return true;
184 return grants.some((g) => GRANTS_USING_AUTHORIZATION_ENDPOINT.includes(g));
185}
186
187
188export function requiresTokenEndpoint(doc) {
189 const grants = grantTypesOf(doc);
190 if (!grants) return true;
191 if (grants.length === 0) return true;
192 return grants.some((g) => GRANTS_USING_TOKEN_ENDPOINT.includes(g));
193}
194
195
196
197
198
199
200
201
202
203export function wellKnownUrl(issuerUrlString) {
204 const u = new URL(issuerUrlString);
205 u.search = '';
206 u.hash = '';
207 const path = u.pathname === '/' ? '' : u.pathname.replace(/\/+$/, '');
208 u.pathname = `/.well-known/oauth-authorization-server${path}`;
209 return u.href;
210}
211
212function issuerMatches(issuerValue, finalUrlString) {
213
214
215 try {
216 const declared = new URL(issuerValue);
217 const fetched = new URL(finalUrlString);
218 const expected = new URL(wellKnownUrl(issuerValue));
219 return declared.origin === fetched.origin && fetched.pathname === expected.pathname;
220 } catch {
221 return false;
222 }
223}
224
225export function analyzeMetadata(doc, finalUrlString) {
226 const issues = [];
227 if (doc === null || typeof doc !== 'object' || Array.isArray(doc)) {
228 issues.push('Discovery document is not a JSON object.');
229 return {
230 valid: false,
231 issuer: null,
232 issuerMatchesFinalUrl: false,
233 requiredKeys: [],
234 missingRequired: ['issuer'],
235 missingRecommended: RECOMMENDED_KEYS.slice(),
236 presentKeys: [],
237 endpoints: [],
238 issues,
239 };
240 }
241
242 const keys = Object.keys(doc);
243 const presentKeys = keys;
244
245 const needsAuthEndpoint = requiresAuthorizationEndpoint(doc);
246 const needsTokenEndpoint = requiresTokenEndpoint(doc);
247 const needsResponseTypes = 'authorization_endpoint' in doc;
248
249 const requiredKeysForThisDoc = ['issuer'];
250 if (needsAuthEndpoint) requiredKeysForThisDoc.push('authorization_endpoint');
251 if (needsTokenEndpoint) requiredKeysForThisDoc.push('token_endpoint');
252 if (needsResponseTypes) requiredKeysForThisDoc.push('response_types_supported');
253
254 const missingRequired = requiredKeysForThisDoc.filter((k) => !(k in doc));
255 for (const k of missingRequired) {
256 if (k === 'authorization_endpoint') {
257 issues.push("Missing required metadata key 'authorization_endpoint' (RFC 8414 section 2: required unless no grant types using the authorization endpoint are supported).");
258 } else if (k === 'token_endpoint') {
259 issues.push("Missing required metadata key 'token_endpoint' (RFC 8414 section 2: required unless only the implicit grant is supported).");
260 } else if (k === 'response_types_supported') {
261 issues.push("Missing required metadata key 'response_types_supported' (RFC 8414 section 2: required when authorization_endpoint is present).");
262 } else {
263 issues.push(`Missing required metadata key '${k}' (RFC 8414 section 2).`);
264 }
265 }
266
267 const missingRecommended = RECOMMENDED_KEYS.filter((k) => !(k in doc));
268
269 let issuer = null;
270 let issuerMatchesFinalUrl = false;
271 if ('issuer' in doc) {
272 issuer = typeof doc.issuer === 'string' ? doc.issuer : null;
273 if (!issuer) {
274 issues.push("'issuer' must be a string URL.");
275 } else {
276 try {
277 const u = new URL(issuer);
278 if (u.protocol !== 'https:') issues.push("'issuer' must be an https URL.");
279 if (u.hash || u.search) issues.push("'issuer' MUST NOT contain a query or fragment component.");
280 } catch {
281 issues.push("'issuer' is not a parseable URL.");
282 }
283 issuerMatchesFinalUrl = issuerMatches(issuer, finalUrlString);
284 if (!issuerMatchesFinalUrl) {
285 issues.push(`issuer value '${issuer}' does not match the URL the document was fetched from; RFC 8414 section 3.3 requires clients to reject mismatched issuer metadata.`);
286 }
287 }
288 }
289
290
291 const endpoints = [];
292 const endpointIssues = [];
293 for (const key of ENDPOINT_KEYS) {
294 if (!(key in doc)) continue;
295 const value = doc[key];
296 if (typeof value !== 'string') {
297 endpointIssues.push(`'${key}' must be a string URL.`);
298 endpoints.push({ key, url: String(value), https: false, parseable: false, issuerHost: false, issues: [`'${key}' must be a string URL.`] });
299 continue;
300 }
301 const https = /^https:\/\//i.test(value);
302 let parseable = true;
303 let issuerHost = false;
304 const local = [];
305 try {
306 const eu = new URL(value);
307 issuerHost = issuer ? eu.origin === new URL(issuer).origin : false;
308 } catch {
309 parseable = false;
310 local.push(`'${key}' is not a parseable URL.`);
311 }
312 if (!https) local.push(`'${key}' must use https (RFC 8414 endpoint URLs must be https).`);
313 endpoints.push({ key, url: value, https, parseable, issuerHost, issues: local });
314 endpointIssues.push(...local);
315 }
316
317
318 if ('scopes_supported' in doc && (!Array.isArray(doc.scopes_supported) || !doc.scopes_supported.every((s) => typeof s === 'string'))) {
319 issues.push("'scopes_supported' must be an array of strings.");
320 }
321
322
323 for (const key of ['response_types_supported', 'token_endpoint_auth_methods_supported']) {
324 if (key in doc && (!Array.isArray(doc[key]) || doc[key].length === 0)) {
325 issues.push(`'${key}' must be a non-empty array.`);
326 }
327 }
328
329
330 for (const key of ['grant_types_supported', 'response_modes_supported', 'code_challenge_methods_supported', 'revocation_endpoint_auth_methods_supported', 'introspection_endpoint_auth_methods_supported', 'token_endpoint_auth_signing_alg_values_supported', 'ui_locales_supported']) {
331 if (key in doc && !Array.isArray(doc[key])) {
332 issues.push(`'${key}' must be an array.`);
333 }
334 }
335
336
337 for (const key of ['service_documentation', 'op_policy_uri', 'op_tos_uri']) {
338 if (key in doc) {
339 if (typeof doc[key] !== 'string') {
340 issues.push(`'${key}' must be a string URL.`);
341 } else {
342 try {
343
344 new URL(doc[key]);
345 } catch {
346 issues.push(`'${key}' is not a parseable URL.`);
347 }
348 }
349 }
350 }
351
352 issues.push(...endpointIssues);
353 if (missingRecommended.length) {
354 issues.push(`Recommended keys missing: ${missingRecommended.join(', ')} (RFC 8414 section 2).`);
355 }
356
357 return {
358 valid: issues.filter((i) => !/Recommended keys missing/.test(i)).length === 0,
359 issuer,
360 issuerMatchesFinalUrl,
361 requiredKeys: requiredKeysForThisDoc.filter((k) => k in doc),
362 missingRequired,
363 missingRecommended,
364 presentKeys,
365 endpoints,
366 needsAuthEndpoint,
367 needsTokenEndpoint,
368 needsResponseTypes,
369 issues,
370 };
371}
372
373
374
375
376
377
378
379
380function evalDocument({ docFetch }) {
381 if (docFetch.error) {
382 return { status: 'missing', note: `Discovery document request failed: ${docFetch.error}`, recommendation: 'Verify the issuer domain is reachable over https and serves an RFC 8414 discovery document.' };
383 }
384 if (docFetch.status !== 200) {
385 return { status: 'missing', note: `Discovery document returned HTTP ${docFetch.status} instead of 200.`, recommendation: 'Serve the discovery document at /.well-known/oauth-authorization-server with a 200 response.' };
386 }
387 if (!/application\/json/i.test(docFetch.contentType)) {
388 return { status: 'warn', note: `Discovery document Content-Type is '${docFetch.contentType || 'none'}'; application/json is expected.`, recommendation: 'Serve the discovery document with Content-Type: application/json.' };
389 }
390 return { status: 'good', note: `Discovery document served with HTTP 200 and Content-Type '${docFetch.contentType}'.` };
391}
392
393function evalJson({ doc }) {
394 if (doc === null) {
395 return { status: 'missing', note: 'Discovery document is not valid JSON or is empty.', recommendation: 'Serve valid JSON metadata in the discovery document body.' };
396 }
397 if (typeof doc !== 'object' || Array.isArray(doc)) {
398 return { status: 'missing', note: 'Discovery document JSON is not an object.', recommendation: 'Serve a JSON object of authorization server metadata in the discovery document body.' };
399 }
400 return { status: 'good', note: `Discovery document is valid JSON with ${Object.keys(doc).length} metadata keys.` };
401}
402
403function evalRequired({ analysis }) {
404 const missing = analysis.missingRequired;
405 if (missing.length) {
406 return { status: 'missing', note: `Missing required metadata keys: ${missing.join(', ')}.`, recommendation: 'Publish all required RFC 8414 metadata keys: issuer, authorization_endpoint (unless no grant types need it), token_endpoint (unless only implicit grant is supported), and response_types_supported (if authorization_endpoint is present).' };
407 }
408 return { status: 'good', note: `All required metadata keys present: ${analysis.requiredKeys.join(', ')}.` };
409}
410
411function evalIssuer({ analysis, docFetch }) {
412 if (!analysis.issuer) {
413 return { status: 'missing', note: "No usable 'issuer' value in metadata.", recommendation: "Set 'issuer' to the issuer identifier URL exactly matching the discovery document location." };
414 }
415 if (!analysis.issuerMatchesFinalUrl) {
416 return { status: 'missing', note: `issuer '${analysis.issuer}' does not match the fetched document URL '${docFetch.finalUrl}'. RFC 8414 clients MUST reject metadata whose issuer does not match.`, recommendation: 'Set the issuer value to exactly the https URL used to construct the discovery document URL.' };
417 }
418 if (!/^https:/i.test(analysis.issuer)) {
419 return { status: 'warn', note: `issuer '${analysis.issuer}' does not use https.`, recommendation: 'Use an https issuer identifier.' };
420 }
421 return { status: 'good', note: `issuer '${analysis.issuer}' matches the discovery document URL.` };
422}
423
424function evalEndpoints({ analysis }) {
425 if (!analysis.endpoints.length) {
426 return { status: 'missing', note: 'No endpoint metadata found (authorization_endpoint and/or token_endpoint are expected).', recommendation: 'Publish endpoint URLs in the discovery metadata.' };
427 }
428 const bad = analysis.endpoints.filter((e) => !e.https || !e.parseable);
429 if (bad.length) {
430 return { status: 'missing', note: `${bad.length} endpoint metadata value(s) are not parseable https URLs: ${bad.map((e) => e.key).join(', ')}.`, recommendation: 'Publish all endpoint metadata values as absolute https URLs.' };
431 }
432 const nonIssuer = analysis.endpoints.filter((e) => !e.issuerHost);
433 if (nonIssuer.length) {
434 return { status: 'warn', note: `${nonIssuer.length} endpoint(s) are hosted off the issuer origin (may be intentional for shared platforms): ${nonIssuer.map((e) => e.key).join(', ')}.` };
435 }
436 return { status: 'good', note: `${analysis.endpoints.length} endpoint(s) documented, all https and on the issuer origin.` };
437}
438
439function evalPkce({ doc }) {
440 const methods = Array.isArray(doc?.code_challenge_methods_supported) ? doc.code_challenge_methods_supported : [];
441 if (!methods.length) {
442 return { status: 'warn', note: 'PKCE is not advertised (no code_challenge_methods_supported). Modern OAuth clients should still send PKCE, but explicit support signals current-spec posture.', recommendation: 'Publish \'code_challenge_methods_supported\': ["S256"] (plain is discouraged).' };
443 }
444 const hasS256 = methods.includes('S256');
445 const hasPlain = methods.includes('plain');
446 if (!hasS256 && hasPlain) {
447 return { status: 'missing', note: "'code_challenge_methods_supported' includes only 'plain', which is discouraged by RFC 7636.", recommendation: 'Support S256 for PKCE code challenge methods.' };
448 }
449 if (hasPlain) {
450 return { status: 'warn', note: `'code_challenge_methods_supported' (${methods.join(', ')}) advertises 'plain' alongside S256; RFC 7636 discourages 'plain'.`, recommendation: "Remove 'plain' from code_challenge_methods_supported and advertise S256 only." };
451 }
452 if (!hasS256) {
453 return { status: 'warn', note: `'code_challenge_methods_supported' (${methods.join(', ')}) does not include S256.`, recommendation: 'Support S256 for PKCE.' };
454 }
455 return { status: 'good', note: `PKCE advertised with methods: ${methods.join(', ')}.` };
456}
457
458function evalRecommended({ analysis }) {
459 const missing = analysis.missingRecommended;
460 if (!missing.length) return { status: 'good', note: 'All recommended metadata keys present.' };
461 return { status: 'warn', note: `Recommended metadata keys missing: ${missing.join(', ')}.`, recommendation: 'Publish jwks_uri, grant_types_supported, token_endpoint_auth_methods_supported, revocation/introspection endpoints, and scopes_supported for full client compatibility.' };
462}
463
464function evalHttps({ docFetch, issuerHttps }) {
465 if (docFetch.error) return { status: 'info', note: 'No response to evaluate.' };
466 if (!docFetch.https) {
467 return { status: 'missing', note: 'Discovery document was not served over https.', recommendation: 'Serve the discovery document over https.' };
468 }
469 if (issuerHttps === false) {
470 return { status: 'warn', note: 'Discovery document is https but the declared issuer is not.', recommendation: 'Use an https issuer identifier.' };
471 }
472 return { status: 'good', note: 'Discovery document served over https.' };
473}
474
475const CHECKS = [
476 { name: 'document', title: 'Discovery document', weight: 20, fn: evalDocument },
477 { name: 'json', title: 'JSON validity', weight: 10, fn: evalJson },
478 { name: 'required-keys', title: 'Required metadata keys', weight: 20, fn: evalRequired },
479 { name: 'issuer', title: 'Issuer match', weight: 20, fn: evalIssuer },
480 { name: 'endpoints', title: 'Endpoint URLs', weight: 10, fn: evalEndpoints },
481 { name: 'pkce', title: 'PKCE support', weight: 10, fn: evalPkce },
482 { name: 'recommended', title: 'Recommended keys', weight: 5, fn: evalRecommended },
483 { name: 'https', title: 'HTTPS', weight: 5, fn: evalHttps },
484];
485
486export function buildCheckReports(context) {
487 const reports = [];
488 let earned = 0;
489 let possible = 0;
490 for (const check of CHECKS) {
491 const evaluation = check.fn(context);
492 if (evaluation.status !== 'info') possible += check.weight;
493 if (evaluation.status === 'good') earned += check.weight;
494 else if (evaluation.status === 'warn') earned += Math.round(check.weight * 0.5);
495 reports.push({
496 name: check.title,
497 check: check.name,
498 status: evaluation.status,
499 note: evaluation.note,
500 weight: check.weight,
501 recommendation: evaluation.recommendation || null,
502 });
503 }
504 return { reports, earned, possible };
505}
506
507export function scoreAudit(earned, possible) {
508 if (possible === 0) return 0;
509 return Math.min(100, Math.max(0, Math.round((earned / possible) * 100)));
510}
511
512export function gradeFromScore(score) {
513 if (score >= 95) return 'A+';
514 if (score >= 85) return 'A';
515 if (score >= 75) return 'B';
516 if (score >= 65) return 'C';
517 if (score >= 50) return 'D';
518 if (score >= 30) return 'E';
519 return 'F';
520}
521
522export function buildRecommendations(reports) {
523 const recs = [];
524 for (const r of reports) {
525 if (r.recommendation && !recs.includes(r.recommendation)) recs.push(r.recommendation);
526 }
527 if (recs.length === 0) {
528 recs.push('Discovery metadata looks spec-conformant. Re-run this audit after authorization server upgrades or domain changes to catch regressions.');
529 }
530 return recs;
531}
532
533
534
535
536
537export async function auditOAuthAuthorizationServer(input) {
538 const issuerRaw = input.startUrl || input.issuer;
539 const timeoutSeconds = Math.min(Math.max(Number(input.timeoutSeconds || DEFAULT_TIMEOUT_SECONDS), 3), 30);
540
541 const issuerUrl = await normalizeAndValidateUrl(issuerRaw);
542 const discoveryUrl = wellKnownUrl(issuerUrl.href);
543 const docFetch = await fetchJson(new URL(discoveryUrl), timeoutSeconds);
544
545 let doc = null;
546 let parseError = null;
547 if (docFetch.body) {
548 try {
549 doc = JSON.parse(docFetch.body);
550 } catch (err) {
551 parseError = err.message;
552 }
553 }
554
555 const analysis = doc && typeof doc === 'object' && !Array.isArray(doc)
556 ? analyzeMetadata(doc, docFetch.finalUrl)
557 : {
558 valid: false,
559 issuer: null,
560 issuerMatchesFinalUrl: false,
561 requiredKeys: [],
562 missingRequired: ['issuer'],
563 missingRecommended: RECOMMENDED_KEYS.slice(),
564 presentKeys: [],
565 endpoints: [],
566 issues: [docFetch.error ? `Request failed: ${docFetch.error}` : parseError ? 'Discovery document is not valid JSON.' : 'Discovery document is empty or not a JSON object.'],
567 };
568
569 const issuerHttps = typeof analysis.issuer === 'string' ? analysis.issuer.startsWith('https://') : null;
570 const context = { docFetch, doc, analysis, issuerHttps };
571 const { reports, earned, possible } = buildCheckReports(context);
572 const score = scoreAudit(earned, possible);
573 const grade = gradeFromScore(score);
574 const recommendations = buildRecommendations(reports);
575
576 const issues = reports
577 .filter((r) => r.status === 'warn' || r.status === 'missing')
578 .map((r) => `${r.name}: ${r.note}`);
579
580 const grantTypes = Array.isArray(doc?.grant_types_supported) ? doc.grant_types_supported : null;
581 const scopes = Array.isArray(doc?.scopes_supported) ? doc.scopes_supported : null;
582 const codeChallengeMethods = Array.isArray(doc?.code_challenge_methods_supported) ? doc.code_challenge_methods_supported : null;
583 const tokenAuthMethods = Array.isArray(doc?.token_endpoint_auth_methods_supported) ? doc.token_endpoint_auth_methods_supported : null;
584 const tokenAuthSigningAlgs = Array.isArray(doc?.token_endpoint_auth_signing_alg_values_supported) ? doc.token_endpoint_auth_signing_alg_values_supported : null;
585 const responseTypes = Array.isArray(doc?.response_types_supported) ? doc.response_types_supported : null;
586 const revocationAuthMethods = Array.isArray(doc?.revocation_endpoint_auth_methods_supported) ? doc.revocation_endpoint_auth_methods_supported : null;
587 const introspectionAuthMethods = Array.isArray(doc?.introspection_endpoint_auth_methods_supported) ? doc.introspection_endpoint_auth_methods_supported : null;
588 const uiLocales = Array.isArray(doc?.ui_locales_supported) ? doc.ui_locales_supported : null;
589
590 return {
591 inputIssuer: issuerRaw,
592 discoveryUrl,
593 finalUrl: docFetch.finalUrl,
594 https: docFetch.https,
595 status: docFetch.status,
596 contentType: docFetch.contentType || null,
597 documentFound: docFetch.ok && docFetch.status === 200,
598 jsonValid: doc !== null,
599 parseError,
600 issuer: analysis.issuer,
601 issuerMatchesFinalUrl: analysis.issuerMatchesFinalUrl,
602 requiredKeys: analysis.requiredKeys,
603 missingRequiredKeys: analysis.missingRequired,
604 missingRecommendedKeys: analysis.missingRecommended,
605 metadataKeyCount: analysis.presentKeys.length,
606 endpoints: analysis.endpoints.map(({ key, url, https, parseable, issuerHost, issues: ei }) => ({ key, url, https, parseable, issuerHost, issues: ei })),
607 responseTypes,
608 grantTypes,
609 scopes,
610 codeChallengeMethods,
611 tokenAuthMethods,
612 tokenAuthSigningAlgs,
613 revocationAuthMethods,
614 introspectionAuthMethods,
615 uiLocales,
616 serviceDocumentation: typeof doc?.service_documentation === 'string' ? doc.service_documentation : null,
617 opPolicyUri: typeof doc?.op_policy_uri === 'string' ? doc.op_policy_uri : null,
618 opTosUri: typeof doc?.op_tos_uri === 'string' ? doc.op_tos_uri : null,
619 pkceSupported: Array.isArray(codeChallengeMethods) && codeChallengeMethods.length > 0,
620 checks: reports,
621 issues,
622 score,
623 grade,
624 checkedAt: new Date().toISOString(),
625 recommendations,
626 error: docFetch.error,
627 };
628}
629
630
631
632
633
634const isExecutedDirectly = process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1];
635
636if (process.env.NODE_ENV !== 'test' && isExecutedDirectly) {
637 await Actor.init();
638 try {
639 const input = await Actor.getInput();
640 const result = await auditOAuthAuthorizationServer(input || {});
641 await Actor.pushData(result);
642 await Actor.setValue('OUTPUT', result);
643 Actor.log.info('OAuth authorization server metadata audit complete', { discoveryUrl: result.discoveryUrl, score: result.score, grade: result.grade });
644 } finally {
645 await Actor.exit();
646 }
647}