import request from 'supertest'; import { USER_SESSION_COOKIE_NAME } from 'src/engine/core-modules/user-session/constants/user-session-cookie-name.constant'; const SERVER_URL = `http://localhost:${APP_PORT}`; const buildAppleOrigin = (): string => { const origin = new URL(SERVER_URL); origin.hostname = process.env.IS_MULTIWORKSPACE_ENABLED === 'true' ? `apple.${origin.hostname}` : origin.hostname; return origin.origin; }; // The app listens on localhost while the seeded workspace resolves from the // apple subdomain. Without a matching Host the server sees a cross-origin // request and refuses to issue the cookie, which is the behaviour under test // everywhere except here. const asWorkspaceRequest = ( agent: request.Test, originOverride?: string, ): request.Test => { const host = new URL(origin).host; return originOverride === undefined ? agent.set('Host', host).set('Origin', origin) : agent.set('Host', host).set('Origin', originOverride); }; const CURRENT_USER_QUERY = ` query CurrentUser { currentUser { id email } } `; const SIGN_OUT_MUTATION = ` mutation SignOut { signOut } `; // A workspace object rather than currentUser: workspace types only exist in the // schema once a workspace is bound, so a request that is allowed to continue // unauthenticated fails with "Cannot query field" instead of an auth error. const FIND_COMPANIES_QUERY = ` query FindCompanies { companies(first: 1) { edges { node { id } } } } `; const AUTH_ERROR_CODES = ['UNAUTHENTICATED', 'FORBIDDEN']; const extractSessionCookie = ( setCookieHeader: string | string[] | undefined, ): string | undefined => { const setCookies = typeof setCookieHeader === 'string' ? [setCookieHeader] : (setCookieHeader ?? []); return setCookies .find((cookie) => cookie.startsWith(`${USER_SESSION_COOKIE_NAME}=`)) ?.split(';')[0]; }; const origin = buildAppleOrigin(); const signInAndGetSessionCookie = async (): Promise => { const loginResponse = await asWorkspaceRequest( request(SERVER_URL).post('/metadata'), ) .send({ query: ` mutation GetLoginTokenFromCredentials( $email: String! $password: String! $origin: String! ) { getLoginTokenFromCredentials( email: $email password: $password origin: $origin ) { loginToken { token } } } `, variables: { email: 'tim@apple.dev', password: 'tim@apple.dev', origin, }, }) .expect(200); const loginToken = loginResponse.body.data.getLoginTokenFromCredentials.loginToken.token; const exchangeResponse = await asWorkspaceRequest( request(SERVER_URL).post('/metadata'), ) .send({ query: ` mutation GetAuthTokensFromLoginToken( $loginToken: String! $origin: String! ) { getAuthTokensFromLoginToken(loginToken: $loginToken, origin: $origin) { tokens { accessOrWorkspaceAgnosticToken { token } } } } `, variables: { loginToken, origin }, }) .expect(200); const sessionCookie = extractSessionCookie( exchangeResponse.headers['set-cookie'], ); expect(sessionCookie).toBeDefined(); return sessionCookie as string; }; describe('Cookie sessions (integration)', () => { let sessionCookie: string; beforeAll(async () => { sessionCookie = await signInAndGetSessionCookie(); }); it('should set a session cookie when exchanging a login token', () => { expect(sessionCookie).toContain(`${USER_SESSION_COOKIE_NAME}=sess_`); }); it('should authenticate a request carrying only the session cookie', async () => { const response = await asWorkspaceRequest( request(SERVER_URL).post('/metadata'), ) .set('Cookie', sessionCookie) .send({ query: CURRENT_USER_QUERY }) .expect(200); expect(response.body.errors).toBeUndefined(); expect(response.body.data.currentUser.email).toBe('tim@apple.dev'); }); it('should reject a cookie-authenticated request from a foreign origin', async () => { const response = await asWorkspaceRequest( request(SERVER_URL).post('/metadata'), 'https://evil.example.org', ) .set('Cookie', sessionCookie) .send({ query: CURRENT_USER_QUERY }); expect(response.status).toBe(403); expect(response.body.error).toBe('CSRF_ORIGIN_MISMATCH'); }); it('should reject a cookie-authenticated request sending no origin', async () => { const response = await request(SERVER_URL) .post('/metadata') .set('Host', new URL(origin).host) .set('Cookie', sessionCookie) .send({ query: CURRENT_USER_QUERY }); expect(response.status).toBe(403); expect(response.body.error).toBe('CSRF_ORIGIN_MISMATCH'); }); // Mints its own session rather than consuming the shared one, so revoking it // cannot break the tests above whatever order they run in. it('should stop authenticating the cookie once signed out', async () => { const disposableSessionCookie = await signInAndGetSessionCookie(); const signOutResponse = await asWorkspaceRequest( request(SERVER_URL).post('/metadata'), ) .set('Cookie', disposableSessionCookie) .send({ query: SIGN_OUT_MUTATION }) .expect(200); expect(signOutResponse.body.errors).toBeUndefined(); const afterSignOut = await asWorkspaceRequest( request(SERVER_URL).post('/metadata'), ) .set('Cookie', disposableSessionCookie) .send({ query: CURRENT_USER_QUERY }); expect(afterSignOut.body.data?.currentUser).toBeFalsy(); expect(afterSignOut.body.errors).toBeDefined(); expect( afterSignOut.body.errors.map( (graphQLError: { extensions?: { code?: string } }) => graphQLError.extensions?.code, ), ).toEqual( expect.arrayContaining([expect.stringMatching(/UNAUTHENTICATED/)]), ); }); it('should answer a revoked cookie with an auth error rather than a schema error', async () => { const disposableSessionCookie = await signInAndGetSessionCookie(); await asWorkspaceRequest(request(SERVER_URL).post('/metadata')) .set('Cookie', disposableSessionCookie) .send({ query: SIGN_OUT_MUTATION }) .expect(200); const response = await asWorkspaceRequest( request(SERVER_URL).post('/graphql'), ) .set('Cookie', disposableSessionCookie) .send({ query: FIND_COMPANIES_QUERY }); expect(response.body.errors).toBeDefined(); const messages = response.body.errors.map( (graphQLError: { message: string }) => graphQLError.message, ); const codes = response.body.errors.map( (graphQLError: { extensions?: { code?: string } }) => graphQLError.extensions?.code, ); // The client only signs out on an auth code. A missing workspace schema // surfaces as "Cannot query field", which it cannot act on. expect(messages.join(' ')).not.toContain('Cannot query field'); expect(codes.some((code: string) => AUTH_ERROR_CODES.includes(code))).toBe( true, ); }); });