diff --git a/.changeset/cookie-ref-resolver.md b/.changeset/cookie-ref-resolver.md new file mode 100644 index 000000000..850e3d9e1 --- /dev/null +++ b/.changeset/cookie-ref-resolver.md @@ -0,0 +1,5 @@ +--- +"@asyncapi/cli": patch +--- + +Forward request Cookie header when resolving remote $refs during API validation diff --git a/src/apps/api/middlewares/validation.middleware.ts b/src/apps/api/middlewares/validation.middleware.ts index a9f046687..613a5961b 100644 --- a/src/apps/api/middlewares/validation.middleware.ts +++ b/src/apps/api/middlewares/validation.middleware.ts @@ -8,7 +8,6 @@ import type { ValidateFunction } from 'ajv'; import { AsyncAPIDocument } from '@asyncapi/converter'; import { ValidationService } from '@services/validation.service'; import { Specification } from '@/domains/models/SpecificationFile'; -import { ParserOptions } from '@asyncapi/parser/cjs/parser'; import { ValidationResult } from '@/interfaces'; export interface ValidationMiddlewareOptions { @@ -200,18 +199,10 @@ export async function validationMiddleware( ); } - const parserConfig: ParserOptions = { - __unstable: { - resolver: { - resolvers: [ - // @TODO: Add Cookie Based Resolvers after migration and understanding some - // details about how to use them in the new parser-js version. - ], - }, - }, - }; - - const validationService = new ValidationService(parserConfig); + const validationService = new ValidationService( + {}, + { cookie: req.header('cookie') || undefined }, + ); const resolveURL = req.header('x-asyncapi-resolve-url') || req.header('referer') || diff --git a/src/domains/services/validation.service.ts b/src/domains/services/validation.service.ts index e87599d5a..16eefa795 100644 --- a/src/domains/services/validation.service.ts +++ b/src/domains/services/validation.service.ts @@ -94,52 +94,66 @@ const fetchGitHubApiContent = async ( return await contentRes.text(); }; +export interface HttpResolverOptions { + /** Optional Cookie header value to forward when fetching remote $refs */ + cookie?: string; +} + /** - * Custom resolver for private repositories + * Custom HTTPS resolver for private repositories and cookie-authenticated refs. + * When `cookie` is provided (e.g. from an API request), it is forwarded on fetch. */ -const createHttpWithAuthResolver = () => ({ - schema: 'https', - order: 1, +export const createHttpWithAuthResolver = (cookieHeader?: string) => { + const cookie = cookieHeader?.trim() || undefined; - read: async (uri: any) => { - let url = uri.toString(); + return { + schema: 'https' as const, + order: 1, - // Default headers - const headers: Record = { - 'User-Agent': 'AsyncAPI-CLI', - }; + read: async (uri: any) => { + let url = uri.toString(); - const authInfo = await ConfigService.getAuthForUrl(url); + // Default headers + const headers: Record = { + 'User-Agent': 'AsyncAPI-CLI', + }; - if (authInfo) { - headers['Authorization'] = `${authInfo.authType} ${authInfo.token}`; - Object.assign(headers, authInfo.headers); // merge custom headers - } + if (cookie) { + headers['Cookie'] = cookie; + } - if (isValidGitHubBlobUrl(url)) { - url = await resolveGitHubBlobUrl(url, headers); - } + const authInfo = await ConfigService.getAuthForUrl(url); - if (url.includes('api.github.com')) { - return await fetchGitHubApiContent(url, headers); - } - if (url.includes('raw.githubusercontent.com')) { - headers['Accept'] = 'application/vnd.github.v3.raw'; + if (authInfo) { + headers['Authorization'] = `${authInfo.authType} ${authInfo.token}`; + Object.assign(headers, authInfo.headers); // merge custom headers + } + + if (isValidGitHubBlobUrl(url)) { + url = await resolveGitHubBlobUrl(url, headers); + } + + if (url.includes('api.github.com')) { + return await fetchGitHubApiContent(url, headers); + } + if (url.includes('raw.githubusercontent.com')) { + headers['Accept'] = 'application/vnd.github.v3.raw'; + const res = await fetchWithErrorHandling( + url, + headers, + 'Failed to fetch GitHub URL', + ); + return await res.text(); + } const res = await fetchWithErrorHandling( url, headers, - 'Failed to fetch GitHub URL', + 'Failed to fetch URL', ); return await res.text(); - } - const res = await fetchWithErrorHandling( - url, - headers, - 'Failed to fetch URL', - ); - return await res.text(); - }, -}); + }, + }; +}; const { writeFile } = promises; @@ -170,10 +184,15 @@ const validFormats = [ export class ValidationService extends BaseService { private parser: Parser; - - constructor(parserOptions: ParserOptions = {}) { - super(); - // Create parser with custom GitHub resolver + private readonly httpOptions: HttpResolverOptions; + + constructor( + parserOptions: ParserOptions = {}, + httpOptions: HttpResolverOptions = {}, + ) { + super(); + this.httpOptions = httpOptions; + // Create parser with custom GitHub / cookie-aware HTTP resolver const customParserOptions = { ...parserOptions, __unstable: { @@ -182,7 +201,7 @@ export class ValidationService extends BaseService { ...parserOptions.__unstable?.resolver, cache: false, resolvers: [ - createHttpWithAuthResolver(), + createHttpWithAuthResolver(httpOptions.cookie), ...(parserOptions.__unstable?.resolver?.resolvers || []) ], }, @@ -305,7 +324,7 @@ export class ValidationService extends BaseService { __unstable: { resolver: { cache: false, - resolvers: [createHttpWithAuthResolver()], + resolvers: [createHttpWithAuthResolver(this.httpOptions.cookie)], }, }, }); diff --git a/test/unit/services/http-auth-resolver.test.ts b/test/unit/services/http-auth-resolver.test.ts new file mode 100644 index 000000000..aa13a17e9 --- /dev/null +++ b/test/unit/services/http-auth-resolver.test.ts @@ -0,0 +1,78 @@ +import { expect } from 'chai'; +import { createHttpWithAuthResolver } from '../../../src/domains/services/validation.service'; +import { ConfigService } from '../../../src/domains/services/config.service'; + +describe('createHttpWithAuthResolver()', () => { + let originalFetch: typeof fetch; + let capturedHeaders: Record | undefined; + let originalGetAuth: typeof ConfigService.getAuthForUrl; + + beforeEach(() => { + originalFetch = global.fetch; + originalGetAuth = ConfigService.getAuthForUrl.bind(ConfigService); + capturedHeaders = undefined; + + global.fetch = async (_input: Parameters[0], init?: RequestInit) => { + capturedHeaders = { ...(init?.headers as Record) }; + return { + ok: true, + statusText: 'OK', + text: async () => 'resolved-content', + json: async () => ({}), + } as Awaited>; + }; + + ConfigService.getAuthForUrl = async () => null; + }); + + afterEach(() => { + global.fetch = originalFetch; + ConfigService.getAuthForUrl = originalGetAuth; + }); + + it('should forward Cookie header when cookie is provided', async () => { + const resolver = createHttpWithAuthResolver('session=abc; theme=dark'); + const result = await resolver.read({ + toString: () => 'https://example.com/schema.yaml', + }); + + expect(result).to.equal('resolved-content'); + expect(capturedHeaders?.Cookie).to.equal('session=abc; theme=dark'); + expect(capturedHeaders?.['User-Agent']).to.equal('AsyncAPI-CLI'); + expect(capturedHeaders?.Authorization).to.equal(undefined); + }); + + it('should not set Cookie header when cookie is omitted', async () => { + const resolver = createHttpWithAuthResolver(); + await resolver.read({ + toString: () => 'https://example.com/schema.yaml', + }); + + expect(capturedHeaders?.Cookie).to.equal(undefined); + }); + + it('should treat empty or whitespace cookie as absent', async () => { + const resolver = createHttpWithAuthResolver(' '); + await resolver.read({ + toString: () => 'https://example.com/schema.yaml', + }); + + expect(capturedHeaders?.Cookie).to.equal(undefined); + }); + + it('should send both Authorization and Cookie when auth is configured', async () => { + ConfigService.getAuthForUrl = async () => ({ + token: 'secret-token', + authType: 'Bearer', + headers: {}, + }); + + const resolver = createHttpWithAuthResolver('session=xyz'); + await resolver.read({ + toString: () => 'https://example.com/private/schema.yaml', + }); + + expect(capturedHeaders?.Cookie).to.equal('session=xyz'); + expect(capturedHeaders?.Authorization).to.equal('Bearer secret-token'); + }); +});