diff --git a/.changeset/three-balloons-collect.md b/.changeset/three-balloons-collect.md new file mode 100644 index 00000000000..5978b3a28ab --- /dev/null +++ b/.changeset/three-balloons-collect.md @@ -0,0 +1,5 @@ +--- +"@firebase/functions": patch +--- + +Fixed issue where Firebase Functions SDK caused CORS errors when connected to emulators in Firebase Studio diff --git a/packages/functions/src/callable.test.ts b/packages/functions/src/callable.test.ts index b969304c89e..724efc39c92 100644 --- a/packages/functions/src/callable.test.ts +++ b/packages/functions/src/callable.test.ts @@ -37,7 +37,11 @@ import { AppCheckInternalComponentName } from '@firebase/app-check-interop-types'; import { makeFakeApp, createTestService } from '../test/utils'; -import { FunctionsService, httpsCallable } from './service'; +import { + FunctionsService, + httpsCallable, + httpsCallableFromURL +} from './service'; import { FUNCTIONS_TYPE } from './constants'; import { FunctionsError } from './error'; @@ -523,9 +527,136 @@ describe('Firebase Functions > Stream', () => { const [_, options] = mockFetch.firstCall.args; expect(options.headers['Authorization']).to.equal('Bearer auth-token'); expect(options.headers['Content-Type']).to.equal('application/json'); + expect(options.credentials).to.equal(undefined); expect(options.headers['Accept']).to.equal('text/event-stream'); }); + it('calls cloud workstations with credentials', async () => { + const authMock: FirebaseAuthInternal = { + getToken: async () => ({ accessToken: 'auth-token' }) + } as unknown as FirebaseAuthInternal; + const authProvider = new Provider( + 'auth-internal', + new ComponentContainer('test') + ); + authProvider.setComponent( + new Component('auth-internal', () => authMock, ComponentType.PRIVATE) + ); + const appCheckMock: FirebaseAppCheckInternal = { + getToken: async () => ({ token: 'app-check-token' }) + } as unknown as FirebaseAppCheckInternal; + const appCheckProvider = new Provider( + 'app-check-internal', + new ComponentContainer('test') + ); + appCheckProvider.setComponent( + new Component( + 'app-check-internal', + () => appCheckMock, + ComponentType.PRIVATE + ) + ); + + const functions = createTestService( + app, + region, + authProvider, + undefined, + appCheckProvider + ); + functions.emulatorOrigin = 'test.cloudworkstations.dev'; + const mockFetch = sinon.stub(functions, 'fetchImpl' as any); + + const mockResponse = new ReadableStream({ + start(controller) { + controller.enqueue( + new TextEncoder().encode('data: {"result":"Success"}\n') + ); + controller.close(); + } + }); + + mockFetch.resolves({ + body: mockResponse, + headers: new Headers({ 'Content-Type': 'text/event-stream' }), + status: 200, + statusText: 'OK' + } as Response); + + const func = httpsCallable, string, string>( + functions, + 'stream' + ); + await func.stream({}); + + expect(mockFetch.calledOnce).to.be.true; + const [_, options] = mockFetch.firstCall.args; + expect(options.credentials).to.equal('include'); + }); + + it('calls streamFromURL cloud workstations with credentials', async () => { + const authMock: FirebaseAuthInternal = { + getToken: async () => ({ accessToken: 'auth-token' }) + } as unknown as FirebaseAuthInternal; + const authProvider = new Provider( + 'auth-internal', + new ComponentContainer('test') + ); + authProvider.setComponent( + new Component('auth-internal', () => authMock, ComponentType.PRIVATE) + ); + const appCheckMock: FirebaseAppCheckInternal = { + getToken: async () => ({ token: 'app-check-token' }) + } as unknown as FirebaseAppCheckInternal; + const appCheckProvider = new Provider( + 'app-check-internal', + new ComponentContainer('test') + ); + appCheckProvider.setComponent( + new Component( + 'app-check-internal', + () => appCheckMock, + ComponentType.PRIVATE + ) + ); + + const functions = createTestService( + app, + region, + authProvider, + undefined, + appCheckProvider + ); + functions.emulatorOrigin = 'test.cloudworkstations.dev'; + const mockFetch = sinon.stub(functions, 'fetchImpl' as any); + + const mockResponse = new ReadableStream({ + start(controller) { + controller.enqueue( + new TextEncoder().encode('data: {"result":"Success"}\n') + ); + controller.close(); + } + }); + + mockFetch.resolves({ + body: mockResponse, + headers: new Headers({ 'Content-Type': 'text/event-stream' }), + status: 200, + statusText: 'OK' + } as Response); + + const func = httpsCallableFromURL, string, string>( + functions, + 'stream' + ); + await func.stream({}); + + expect(mockFetch.calledOnce).to.be.true; + const [_, options] = mockFetch.firstCall.args; + expect(options.credentials).to.equal('include'); + }); + it('aborts during initial fetch', async () => { const controller = new AbortController(); diff --git a/packages/functions/src/service.ts b/packages/functions/src/service.ts index 57504a4c7a4..6e2eddda3a2 100644 --- a/packages/functions/src/service.ts +++ b/packages/functions/src/service.ts @@ -185,7 +185,7 @@ export function connectFunctionsEmulator( }://${host}:${port}`; // Workaround to get cookies in Firebase Studio if (useSsl) { - void pingServer(functionsInstance.emulatorOrigin); + void pingServer(functionsInstance.emulatorOrigin + '/backends'); updateEmulatorBanner('Functions', true); } } @@ -245,18 +245,29 @@ export function httpsCallableFromURL< return callable as HttpsCallable; } +function getCredentials( + functionsInstance: FunctionsService +): 'include' | undefined { + return functionsInstance.emulatorOrigin && + isCloudWorkstation(functionsInstance.emulatorOrigin) + ? 'include' + : undefined; +} + /** * Does an HTTP POST and returns the completed response. * @param url The url to post to. * @param body The JSON body of the post. * @param headers The HTTP headers to include in the request. + * @param functionsInstance functions instance that is calling postJSON * @return A Promise that will succeed when the request finishes. */ async function postJSON( url: string, body: unknown, headers: { [key: string]: string }, - fetchImpl: typeof fetch + fetchImpl: typeof fetch, + functionsInstance: FunctionsService ): Promise { headers['Content-Type'] = 'application/json'; @@ -265,7 +276,8 @@ async function postJSON( response = await fetchImpl(url, { method: 'POST', body: JSON.stringify(body), - headers + headers, + credentials: getCredentials(functionsInstance) }); } catch (e) { // This could be an unhandled error on the backend, or it could be a @@ -353,7 +365,13 @@ async function callAtURL( const failAfterHandle = failAfter(timeout); const response = await Promise.race([ - postJSON(url, body, headers, functionsInstance.fetchImpl), + postJSON( + url, + body, + headers, + functionsInstance.fetchImpl, + functionsInstance + ), failAfterHandle.promise, functionsInstance.cancelAllRequests ]); @@ -439,7 +457,8 @@ async function streamAtURL( method: 'POST', body: JSON.stringify(body), headers, - signal: options?.signal + signal: options?.signal, + credentials: getCredentials(functionsInstance) }); } catch (e) { if (e instanceof Error && e.name === 'AbortError') {