Files
twenty/packages/twenty-server/test/integration/graphql/suites/auth/cookie-session/cookie-session.integration-spec.ts
T

267 lines
7.7 KiB
TypeScript

import request from 'supertest';
import { deleteConfigVariable } from 'test/integration/twenty-config/utils/delete-config-variable.util';
import { updateConfigVariable } from 'test/integration/twenty-config/utils/update-config-variable.util';
import { USER_SESSION_COOKIE_NAME } from 'src/engine/core-modules/user-session/constants/user-session-cookie-name.constant';
const AUTH_COOKIE_SESSIONS_ENABLED_KEY = 'AUTH_COOKIE_SESSIONS_ENABLED';
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<string> => {
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 () => {
await updateConfigVariable({
input: { key: AUTH_COOKIE_SESSIONS_ENABLED_KEY, value: true },
});
sessionCookie = await signInAndGetSessionCookie();
});
afterAll(async () => {
await deleteConfigVariable({
input: { key: AUTH_COOKIE_SESSIONS_ENABLED_KEY },
});
});
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,
);
});
});