1import { Actor } from 'apify';
2import dns from 'node:dns/promises';
3import net from 'node:net';
4import { fileURLToPath } from 'node:url';
5
6const USER_AGENT = 'OIDCDiscoveryAuditor/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
133const REQUIRED_KEYS = ['issuer', 'authorization_endpoint', 'jwks_uri', 'response_types_supported'];
134const RECOMMENDED_KEYS = ['token_endpoint', 'id_token_signing_alg_values_supported', 'subject_types_supported'];
135const ENDPOINT_KEYS = [
136 'authorization_endpoint',
137 'token_endpoint',
138 'userinfo_endpoint',
139 'jwks_uri',
140 'registration_endpoint',
141 'end_session_endpoint',
142 'check_session_iframe',
143 'revocation_endpoint',
144 'introspection_endpoint',
145 'pushed_authorization_request_endpoint',
146 'device_authorization_endpoint',
147 'backchannel_authentication_endpoint',
148];
149const SIGNING_ALGS = [
150 'RS256', 'RS384', 'RS512', 'PS256', 'PS384', 'PS512',
151 'ES256', 'ES256K', 'ES384', 'ES512', 'EdDSA',
152 'HS256', 'HS384', 'HS512',
153];
154
155const URI_TEMPLATES = {
156 openid: /.well-known\/openid-configuration$/,
157 oauth: /.well-known\/oauth-authorization-server(\/|$)/,
158};
159
160export function wellKnownUrl(issuerUrlString, type) {
161 const u = new URL(issuerUrlString);
162 if (u.pathname !== '/' && u.pathname !== '') {
163 throw new Error('issuer URL must not contain a path (use the issuer root domain)');
164 }
165 u.search = '';
166 u.hash = '';
167 if (type === 'oauth') {
168 u.pathname = '/.well-known/oauth-authorization-server';
169 } else {
170 u.pathname = '/.well-known/openid-configuration';
171 }
172 return u.href;
173}
174
175function issuerMatches(issuerValue, finalUrlString) {
176
177
178
179 try {
180 const declared = new URL(issuerValue);
181 const fetched = new URL(finalUrlString);
182 const basePath = declared.pathname.replace(/\/+$/, '');
183 const expectedPath = `${basePath}/.well-known/openid-configuration`;
184 return declared.origin === fetched.origin && fetched.pathname === expectedPath;
185 } catch {
186 return false;
187 }
188}
189
190export function analyzeMetadata(doc, finalUrlString) {
191 const issues = [];
192 if (doc === null || typeof doc !== 'object' || Array.isArray(doc)) {
193 issues.push('Discovery document is not a JSON object.');
194 return { valid: false, issuer: null, issuerMatchesFinalUrl: false, requiredKeys: [], issues, missingRequired: REQUIRED_KEYS.slice(), presentKeys: [] };
195 }
196
197 const keys = Object.keys(doc);
198 const missingRequired = REQUIRED_KEYS.filter((k) => !(k in doc));
199 for (const k of missingRequired) issues.push(`Missing required metadata key '${k}' (OIDC Discovery 1.0 section 3).`);
200
201 const missingRecommended = RECOMMENDED_KEYS.filter((k) => !(k in doc));
202 const presentKeys = keys;
203
204 let issuer = null;
205 let issuerMatchesFinalUrl = false;
206 if ('issuer' in doc) {
207 issuer = typeof doc.issuer === 'string' ? doc.issuer : null;
208 if (!issuer) {
209 issues.push("'issuer' must be a string URL.");
210 } else {
211 try {
212 const u = new URL(issuer);
213 if (!['http:', 'https:'].includes(u.protocol)) issues.push("'issuer' must be an http or https URL.");
214 if (u.hash || u.search) issues.push("'issuer' MUST NOT contain a query or fragment component.");
215 } catch {
216 issues.push("'issuer' is not a parseable URL.");
217 }
218 issuerMatchesFinalUrl = issuerMatches(issuer, finalUrlString);
219 if (!issuerMatchesFinalUrl) {
220 issues.push(`issuer value '${issuer}' does not match the URL the document was fetched from; OIDC clients MUST reject mismatched issuer metadata.`);
221 }
222 }
223 }
224
225
226 const endpoints = [];
227 const endpointIssues = [];
228 for (const key of ENDPOINT_KEYS) {
229 if (!(key in doc)) continue;
230 const value = doc[key];
231 if (typeof value !== 'string') {
232 endpointIssues.push(`'${key}' must be a string URL.`);
233 endpoints.push({ key, url: String(value), https: false, parseable: false, issuerHost: false, issues: ["'must be a string URL'"] });
234 continue;
235 }
236 const https = /^https:\/\//i.test(value);
237 let parseable = true;
238 let issuerHost = false;
239 const local = [];
240 try {
241 const eu = new URL(value);
242 issuerHost = issuer ? eu.origin === new URL(issuer).origin : false;
243 } catch {
244 parseable = false;
245 local.push(`'${key}' is not a parseable URL.`);
246 }
247 if (!https) local.push(`'${key}' must use https (OIDC Discovery requires https endpoint URLs).`);
248 endpoints.push({ key, url: value, https, parseable, issuerHost, issues: local });
249 endpointIssues.push(...local);
250 }
251
252
253 if ('scopes_supported' in doc && (!Array.isArray(doc.scopes_supported) || !doc.scopes_supported.every((s) => typeof s === 'string'))) {
254 issues.push("'scopes_supported' must be an array of strings.");
255 }
256
257
258 for (const key of ['response_types_supported', 'subject_types_supported', 'id_token_signing_alg_values_supported', 'token_endpoint_auth_methods_supported']) {
259 if (key in doc && (!Array.isArray(doc[key]) || doc[key].length === 0)) {
260 issues.push(`'${key}' must be a non-empty array.`);
261 }
262 }
263
264
265 for (const key of ['response_modes_supported', 'grant_types_supported', 'code_challenge_methods_supported']) {
266 if (key in doc && !Array.isArray(doc[key])) {
267 issues.push(`'${key}' must be an array.`);
268 }
269 }
270
271
272 if (Array.isArray(doc.id_token_signing_alg_values_supported)) {
273 const unknown = doc.id_token_signing_alg_values_supported.filter((a) => !SIGNING_ALGS.includes(a));
274 if (unknown.length) {
275 issues.push(`'id_token_signing_alg_values_supported' contains unrecognized algorithms: ${unknown.join(', ')}.`);
276 }
277 if (doc.id_token_signing_alg_values_supported.includes('none')) {
278 issues.push("'id_token_signing_alg_values_supported' includes 'none' (unsigned ID tokens); this is prohibited for ID tokens.");
279 }
280 }
281
282
283 if (Array.isArray(doc.subject_types_supported)) {
284 const invalid = doc.subject_types_supported.filter((t) => t !== 'public' && t !== 'pairwise');
285 if (invalid.length) {
286 issues.push(`'subject_types_supported' contains invalid values (must be 'public' or 'pairwise'): ${invalid.join(', ')}.`);
287 }
288 }
289
290 issues.push(...endpointIssues);
291 if (missingRecommended.length) {
292 issues.push(`Recommended keys missing: ${missingRecommended.join(', ')} (OIDC Discovery 1.0 section 3).`);
293 }
294
295 return {
296 valid: issues.filter((i) => !/Recommended keys missing/.test(i)).length === 0,
297 issuer,
298 issuerMatchesFinalUrl,
299 requiredKeys: REQUIRED_KEYS.filter((k) => k in doc),
300 missingRequired,
301 missingRecommended,
302 presentKeys,
303 endpoints,
304 issues,
305 };
306}
307
308
309
310
311
312
313
314
315function evalDocument({ docFetch }) {
316 if (docFetch.error) {
317 return { status: 'missing', note: `Discovery document request failed: ${docFetch.error}`, recommendation: 'Verify the issuer domain is reachable over https and serves a discovery document.' };
318 }
319 if (docFetch.status !== 200) {
320 return { status: 'missing', note: `Discovery document returned HTTP ${docFetch.status} instead of 200.`, recommendation: 'Serve the discovery document at /.well-known/openid-configuration with a 200 response.' };
321 }
322 if (!/application\/json/i.test(docFetch.contentType)) {
323 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.' };
324 }
325 return { status: 'good', note: `Discovery document served with HTTP 200 and Content-Type '${docFetch.contentType}'.` };
326}
327
328function evalJson({ doc }) {
329 if (doc === null) {
330 return { status: 'missing', note: 'Discovery document is not valid JSON or is empty.', recommendation: 'Serve valid JSON metadata in the discovery document body.' };
331 }
332 if (typeof doc !== 'object' || Array.isArray(doc)) {
333 return { status: 'missing', note: 'Discovery document JSON is not an object.', recommendation: 'Serve a JSON object of provider metadata in the discovery document body.' };
334 }
335 return { status: 'good', note: `Discovery document is valid JSON with ${Object.keys(doc).length} metadata keys.` };
336}
337
338function evalRequired({ analysis }) {
339 const missing = analysis.missingRequired;
340 if (missing.length) {
341 return { status: 'missing', note: `Missing required metadata keys: ${missing.join(', ')}.`, recommendation: 'Publish all required OIDC Discovery metadata keys: issuer, authorization_endpoint, jwks_uri, response_types_supported.' };
342 }
343 return { status: 'good', note: `All required metadata keys present: ${REQUIRED_KEYS.join(', ')}.` };
344}
345
346function evalIssuer({ analysis, docFetch }) {
347 if (!analysis.issuer) {
348 return { status: 'missing', note: "No usable 'issuer' value in metadata.", recommendation: "Set 'issuer' to the issuer identifier URL exactly matching the discovery document location." };
349 }
350 if (!analysis.issuerMatchesFinalUrl) {
351 return { status: 'missing', note: `issuer '${analysis.issuer}' does not match the fetched document URL '${docFetch.finalUrl}'. OIDC clients MUST reject metadata whose issuer does not match.`, recommendation: 'Set the issuer value to exactly the https URL the discovery document is published at.' };
352 }
353 if (!/^https:/i.test(analysis.issuer)) {
354 return { status: 'warn', note: `issuer '${analysis.issuer}' does not use https.`, recommendation: 'Use an https issuer identifier.' };
355 }
356 return { status: 'good', note: `issuer '${analysis.issuer}' matches the discovery document URL.` };
357}
358
359function evalEndpoints({ analysis }) {
360 if (!analysis.endpoints.length) {
361 return { status: 'missing', note: 'No endpoint metadata found (authorization_endpoint and jwks_uri are required).', recommendation: 'Publish endpoint URLs in the discovery metadata.' };
362 }
363 const bad = analysis.endpoints.filter((e) => !e.https || !e.parseable);
364 if (bad.length) {
365 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.' };
366 }
367 const nonIssuer = analysis.endpoints.filter((e) => !e.issuerHost);
368 if (nonIssuer.length) {
369 return { status: 'warn', note: `${nonIssuer.length} endpoint(s) are hosted off the issuer origin (may be intentional for shared IdP platforms): ${nonIssuer.map((e) => e.key).join(', ')}.` };
370 }
371 return { status: 'good', note: `${analysis.endpoints.length} endpoint(s) documented, all https and on the issuer origin.` };
372}
373
374function evalSigningAlgs({ doc, analysis }) {
375 const algs = Array.isArray(doc?.id_token_signing_alg_values_supported) ? doc.id_token_signing_alg_values_supported : [];
376 if (!algs.length) {
377 return { status: 'warn', note: "'id_token_signing_alg_values_supported' is not published; clients default to RS256.", recommendation: "Publish 'id_token_signing_alg_values_supported' explicitly." };
378 }
379 if (algs.includes('none')) {
380 return { status: 'missing', note: "'id_token_signing_alg_values_supported' includes 'none' — unsigned ID tokens are prohibited.", recommendation: "Remove 'none' from supported ID token signing algorithms." };
381 }
382 return { status: 'good', note: `ID token signing algorithms published: ${algs.join(', ')}.` };
383}
384
385function evalPkce({ doc }) {
386 const methods = Array.isArray(doc?.code_challenge_methods_supported) ? doc.code_challenge_methods_supported : [];
387 if (!methods.length) {
388 return { status: 'warn', note: 'PKCE is not advertised (no code_challenge_methods_supported). Most modern clients still send PKCE, but explicit support signals current-spec posture.', recommendation: "Publish 'code_challenge_methods_supported': [\"S256\"] (plain is discouraged)." };
389 }
390 const hasS256 = methods.includes('S256');
391 const hasPlain = methods.includes('plain');
392 if (!hasS256 && hasPlain) {
393 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.' };
394 }
395 if (hasPlain) {
396 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." };
397 }
398 if (!hasS256) {
399 return { status: 'warn', note: `'code_challenge_methods_supported' (${methods.join(', ')}) does not include S256.`, recommendation: 'Support S256 for PKCE.' };
400 }
401 return { status: 'good', note: `PKCE advertised with methods: ${methods.join(', ')}.` };
402}
403
404function evalRecommended({ analysis }) {
405 const missing = analysis.missingRecommended;
406 if (!missing.length) return { status: 'good', note: 'All recommended metadata keys present.' };
407 return { status: 'warn', note: `Recommended metadata keys missing: ${missing.join(', ')}.`, recommendation: 'Publish token_endpoint, id_token_signing_alg_values_supported, and subject_types_supported for full client compatibility.' };
408}
409
410function evalHttps({ docFetch, issuerHttps }) {
411 if (docFetch.error) return { status: 'info', note: 'No response to evaluate.' };
412 if (!docFetch.https) {
413 return { status: 'missing', note: 'Discovery document was not served over https.', recommendation: 'Serve the discovery document over https.' };
414 }
415 if (issuerHttps === false) {
416 return { status: 'warn', note: 'Discovery document is https but the declared issuer is not.', recommendation: 'Use an https issuer identifier.' };
417 }
418 return { status: 'good', note: 'Discovery document served over https.' };
419}
420
421const CHECKS = [
422 { name: 'document', title: 'Discovery document', weight: 20, fn: evalDocument },
423 { name: 'json', title: 'JSON validity', weight: 10, fn: evalJson },
424 { name: 'required-keys', title: 'Required metadata keys', weight: 20, fn: evalRequired },
425 { name: 'issuer', title: 'Issuer match', weight: 20, fn: evalIssuer },
426 { name: 'endpoints', title: 'Endpoint URLs', weight: 10, fn: evalEndpoints },
427 { name: 'signing-algs', title: 'ID token signing algorithms', weight: 5, fn: evalSigningAlgs },
428 { name: 'pkce', title: 'PKCE support', weight: 10, fn: evalPkce },
429 { name: 'recommended', title: 'Recommended keys', weight: 5, fn: evalRecommended },
430];
431
432export function buildCheckReports(context) {
433 const reports = [];
434 let earned = 0;
435 let possible = 0;
436 for (const check of CHECKS) {
437 const evaluation = check.fn(context);
438 if (evaluation.status !== 'info') possible += check.weight;
439 if (evaluation.status === 'good') earned += check.weight;
440 else if (evaluation.status === 'warn') earned += Math.round(check.weight * 0.5);
441 reports.push({
442 name: check.title,
443 check: check.name,
444 status: evaluation.status,
445 note: evaluation.note,
446 weight: check.weight,
447 recommendation: evaluation.recommendation || null,
448 });
449 }
450 return { reports, earned, possible };
451}
452
453export function scoreAudit(earned, possible) {
454 if (possible === 0) return 0;
455 return Math.min(100, Math.max(0, Math.round((earned / possible) * 100)));
456}
457
458export function gradeFromScore(score) {
459 if (score >= 95) return 'A+';
460 if (score >= 85) return 'A';
461 if (score >= 75) return 'B';
462 if (score >= 65) return 'C';
463 if (score >= 50) return 'D';
464 if (score >= 30) return 'E';
465 return 'F';
466}
467
468export function buildRecommendations(reports) {
469 const recs = [];
470 for (const r of reports) {
471 if (r.recommendation && !recs.includes(r.recommendation)) recs.push(r.recommendation);
472 }
473 if (recs.length === 0) {
474 recs.push('Discovery metadata looks spec-conformant. Re-run this audit after IdP upgrades or domain changes to catch regressions.');
475 }
476 return recs;
477}
478
479
480
481
482
483export async function auditOidcDiscovery(input) {
484 const issuerRaw = input.startUrl || input.issuer;
485 const timeoutSeconds = Math.min(Math.max(Number(input.timeoutSeconds || DEFAULT_TIMEOUT_SECONDS), 3), 30);
486
487 const issuerUrl = await normalizeAndValidateUrl(issuerRaw);
488 const discoveryUrl = wellKnownUrl(issuerUrl.href, 'openid');
489 const docFetch = await fetchJson(new URL(discoveryUrl), timeoutSeconds);
490
491 let doc = null;
492 let parseError = null;
493 if (docFetch.body) {
494 try {
495 doc = JSON.parse(docFetch.body);
496 } catch (err) {
497 parseError = err.message;
498 }
499 }
500
501 const analysis = doc && typeof doc === 'object' && !Array.isArray(doc)
502 ? analyzeMetadata(doc, docFetch.finalUrl)
503 : {
504 valid: false,
505 issuer: null,
506 issuerMatchesFinalUrl: false,
507 requiredKeys: [],
508 missingRequired: REQUIRED_KEYS.slice(),
509 missingRecommended: RECOMMENDED_KEYS.slice(),
510 presentKeys: [],
511 endpoints: [],
512 issues: [docFetch.error ? `Request failed: ${docFetch.error}` : parseError ? 'Discovery document is not valid JSON.' : 'Discovery document is empty or not a JSON object.'],
513 };
514
515 const issuerHttps = typeof analysis.issuer === 'string' ? analysis.issuer.startsWith('https://') : null;
516 const context = { docFetch, doc, analysis, issuerHttps };
517 const { reports, earned, possible } = buildCheckReports(context);
518 const score = scoreAudit(earned, possible);
519 const grade = gradeFromScore(score);
520 const recommendations = buildRecommendations(reports);
521
522 const issues = reports
523 .filter((r) => r.status === 'warn' || r.status === 'missing')
524 .map((r) => `${r.name}: ${r.note}`);
525
526 const grantTypes = Array.isArray(doc?.grant_types_supported) ? doc.grant_types_supported : null;
527 const scopes = Array.isArray(doc?.scopes_supported) ? doc.scopes_supported : null;
528 const signingAlgs = Array.isArray(doc?.id_token_signing_alg_values_supported) ? doc.id_token_signing_alg_values_supported : null;
529 const codeChallengeMethods = Array.isArray(doc?.code_challenge_methods_supported) ? doc.code_challenge_methods_supported : null;
530 const tokenAuthMethods = Array.isArray(doc?.token_endpoint_auth_methods_supported) ? doc.token_endpoint_auth_methods_supported : null;
531 const subjectTypes = Array.isArray(doc?.subject_types_supported) ? doc.subject_types_supported : null;
532 const responseTypes = Array.isArray(doc?.response_types_supported) ? doc.response_types_supported : null;
533
534 return {
535 inputIssuer: issuerRaw,
536 discoveryUrl,
537 finalUrl: docFetch.finalUrl,
538 https: docFetch.https,
539 status: docFetch.status,
540 contentType: docFetch.contentType || null,
541 documentFound: docFetch.ok && docFetch.status === 200,
542 jsonValid: doc !== null,
543 parseError,
544 issuer: analysis.issuer,
545 issuerMatchesFinalUrl: analysis.issuerMatchesFinalUrl,
546 requiredKeys: analysis.requiredKeys,
547 missingRequiredKeys: analysis.missingRequired,
548 missingRecommendedKeys: analysis.missingRecommended,
549 metadataKeyCount: analysis.presentKeys.length,
550 endpoints: analysis.endpoints.map(({ key, url, https, parseable, issuerHost, issues: ei }) => ({ key, url, https, parseable, issuerHost, issues: ei })),
551 responseTypes,
552 subjectTypes,
553 signingAlgs,
554 codeChallengeMethods,
555 tokenAuthMethods,
556 scopes,
557 grantTypes,
558 pkceSupported: Array.isArray(codeChallengeMethods) && codeChallengeMethods.length > 0,
559 checks: reports,
560 issues,
561 score,
562 grade,
563 checkedAt: new Date().toISOString(),
564 recommendations,
565 error: docFetch.error,
566 };
567}
568
569
570
571
572
573const isExecutedDirectly = process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1];
574
575if (process.env.NODE_ENV !== 'test' && isExecutedDirectly) {
576 await Actor.init();
577 try {
578 const input = await Actor.getInput();
579 const result = await auditOidcDiscovery(input || {});
580 await Actor.pushData(result);
581 await Actor.setValue('OUTPUT', result);
582 Actor.log.info('OIDC discovery audit complete', { discoveryUrl: result.discoveryUrl, score: result.score, grade: result.grade });
583 } finally {
584 await Actor.exit();
585 }
586}