Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/cookie-ref-resolver.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@asyncapi/cli": patch
---

Forward request Cookie header when resolving remote $refs during API validation
17 changes: 4 additions & 13 deletions src/apps/api/middlewares/validation.middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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') ||
Expand Down
97 changes: 58 additions & 39 deletions src/domains/services/validation.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string> = {
'User-Agent': 'AsyncAPI-CLI',
};
read: async (uri: any) => {
let url = uri.toString();

const authInfo = await ConfigService.getAuthForUrl(url);
// Default headers
const headers: Record<string, string> = {
'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;

Expand Down Expand Up @@ -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: {
Expand All @@ -182,7 +201,7 @@ export class ValidationService extends BaseService {
...parserOptions.__unstable?.resolver,
cache: false,
resolvers: [
createHttpWithAuthResolver(),
createHttpWithAuthResolver(httpOptions.cookie),
...(parserOptions.__unstable?.resolver?.resolvers || [])
],
},
Expand Down Expand Up @@ -305,7 +324,7 @@ export class ValidationService extends BaseService {
__unstable: {
resolver: {
cache: false,
resolvers: [createHttpWithAuthResolver()],
resolvers: [createHttpWithAuthResolver(this.httpOptions.cookie)],
},
},
});
Expand Down
78 changes: 78 additions & 0 deletions test/unit/services/http-auth-resolver.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, string> | undefined;
let originalGetAuth: typeof ConfigService.getAuthForUrl;

beforeEach(() => {
originalFetch = global.fetch;
originalGetAuth = ConfigService.getAuthForUrl.bind(ConfigService);
capturedHeaders = undefined;

global.fetch = async (_input: Parameters<typeof fetch>[0], init?: RequestInit) => {
capturedHeaders = { ...(init?.headers as Record<string, string>) };
return {
ok: true,
statusText: 'OK',
text: async () => 'resolved-content',
json: async () => ({}),
} as Awaited<ReturnType<typeof fetch>>;
};

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');
});
});
Loading