Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Set httpOnly flag in cookie #2591

Merged
merged 5 commits into from
Dec 21, 2023
Merged
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
102 changes: 47 additions & 55 deletions api.planx.uk/modules/auth/controller.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,6 @@
import { CookieOptions, RequestHandler, Response } from "express";
import { Request } from "express-jwt";

import { isLiveEnv } from "../../helpers";

export const failedLogin: RequestHandler = (_req, _res, next) =>
next({
status: 401,
Expand All @@ -17,61 +15,55 @@ export const logout: RequestHandler = (req, res) => {
};

export const handleSuccess = (req: Request, res: Response) => {
if (req.user) {
const { returnTo = process.env.EDITOR_URL_EXT } = req.session!;

const domain = (() => {
if (isLiveEnv()) {
if (returnTo?.includes("editor.planx.")) {
// user is logging in to staging from editor.planx.dev
// or production from editor.planx.uk
return `.${new URL(returnTo).host}`;
} else {
// user is logging in from either a netlify preview build,
// or from localhost, to staging (or production... temporarily)
return undefined;
}
} else {
// user is logging in from localhost, to development
return "localhost";
}
})();

if (domain) {
// As domain is set, we know that we're either redirecting back to
// editor.planx.dev/login, editor.planx.uk, or localhost:PORT
// (if this code is running in development). With the respective
// domain set in the cookie.
const cookie: CookieOptions = {
domain,
maxAge: new Date(
new Date().setFullYear(new Date().getFullYear() + 1),
).getTime(),
httpOnly: false,
};

if (isLiveEnv()) {
cookie.secure = true;
cookie.sameSite = "none";
}

res.cookie("jwt", req.user.jwt, cookie);

res.redirect(returnTo);
} else {
// Redirect back to localhost:PORT/login (if this API is in staging or
// production), or a netlify preview build url. As the login page is on a
// different domain to whatever this API is running on, we can't set a
// cookie. To solve this issue we inject the JWT into the return url as
// a parameter that can be extracted by the frontend code instead.
const url = new URL(returnTo);
url.searchParams.set("jwt", req.user.jwt);
res.redirect(url.href);
}
} else {
res.json({
if (!req.user) {
return res.json({
message: "no user",
success: true,
});
}

// Check referrer of original request
// This means requests from Pizzas to the staging API will not get flagged as `isStagingOrProd`
const { returnTo = process.env.EDITOR_URL_EXT } = req.session!;
if (!returnTo) throw Error("Can't generate returnTo URL from session");

const isStagingOrProd = returnTo.includes("editor.planx.");

isStagingOrProd
? setJWTCookie(returnTo, res, req)
: setJWTSearchParams(returnTo, res, req);
};

/**
* Handle auth for staging and production
*
* Use a httpOnly cookie to pass the JWT securely back to the client.
* The client will then use the JWT to make authenticated requests to the API.
*/
function setJWTCookie(returnTo: string, res: Response, req: Request) {
const cookie: CookieOptions = {
domain: `.${new URL(returnTo).host}`,
maxAge: new Date(
new Date().setFullYear(new Date().getFullYear() + 1),
).getTime(),
httpOnly: true,
secure: true,
sameSite: "none",
};

res.cookie("jwt", req.user!.jwt, cookie);

res.redirect(returnTo);
}

/**
* Handle auth for local development and Pizzas
*
* We can't use cookies cross-domain.
* Inject the JWT into the return URL, which can then be set as a cookie by the frontend
*/
function setJWTSearchParams(returnTo: string, res: Response, req: Request) {
const url = new URL(returnTo);
url.searchParams.set("jwt", req.user!.jwt);
res.redirect(url.href);
}
11 changes: 2 additions & 9 deletions api.planx.uk/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,19 +38,12 @@ useSwaggerDocs(app);

app.set("trust proxy", 1);

app.use((req, res, next) => {
res.header("Access-Control-Allow-Origin", req.headers.origin);
res.header(
"Access-Control-Allow-Headers",
"Origin, X-Requested-With, Content-Type, Accept",
);
next();
});

app.use(
cors({
credentials: true,
methods: "*",
origin: process.env.EDITOR_URL_EXT,
allowedHeaders: "Origin, X-Requested-With, Content-Type, Accept",
}),
);

Expand Down
7 changes: 2 additions & 5 deletions e2e/tests/ui-driven/src/create-flow/helpers.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,8 @@
import { Browser, Page, Request } from "@playwright/test";
import { createAuthenticatedSession } from "../globalHelpers";

export const isGetUserRequest = (req: Request) => {
const isHasuraRequest = req.url().includes("/graphql");
const isGetUserOperation = req.postData()?.toString().includes("GetUserById");
return Boolean(isHasuraRequest && isGetUserOperation);
};
export const isGetUserRequest = (req: Request) =>
req.url().includes("/user/me");

export async function getAdminPage({
browser,
Expand Down
3 changes: 1 addition & 2 deletions editor.planx.uk/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@
"@tiptap/extension-history": "^2.0.3",
"@tiptap/extension-image": "^2.0.3",
"@tiptap/extension-italic": "^2.0.3",
"@tiptap/extension-link": "^2.0.3",
"@tiptap/extension-link": "^2.1.13",
Copy link
Contributor Author

@DafyddLlyr DafyddLlyr Dec 21, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This was the issue that was giving me so much grief (causing E2E tests to fail) - I managed to recreate on the Pizza whilst testing 🤯

I still really don't get the relationship here or what the real root cause is, but it was code for this extension in RichTextInput.tsx which was causing issues.

I'll pick up a PR to revert #2589 shortly.

"@tiptap/extension-list-item": "^2.0.3",
"@tiptap/extension-mention": "^2.1.8",
"@tiptap/extension-ordered-list": "^2.1.8",
Expand All @@ -52,7 +52,6 @@
"graphql-tag": "^2.12.6",
"immer": "^9.0.21",
"js-cookie": "^3.0.5",
"jwt-decode": "^4.0.0",
"lodash": "^4.17.21",
"marked": "^4.3.0",
"mathjs": "^11.8.2",
Expand Down
16 changes: 4 additions & 12 deletions editor.planx.uk/pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

37 changes: 12 additions & 25 deletions editor.planx.uk/src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@ import { ApolloProvider } from "@apollo/client";
import CssBaseline from "@mui/material/CssBaseline";
import { StyledEngineProvider, ThemeProvider } from "@mui/material/styles";
import { MyMap } from "@opensystemslab/map";
import { jwtDecode } from "jwt-decode";
import { getCookie, setCookie } from "lib/cookie";
import ErrorPage from "pages/ErrorPage";
import { AnalyticsProvider } from "pages/FlowEditor/lib/analyticsProvider";
Expand All @@ -36,30 +35,18 @@ if (!window.customElements.get("my-map")) {
}

const hasJWT = (): boolean | void => {
let jwt = getCookie("jwt");
if (jwt) {
try {
if (
Number(
(jwtDecode(jwt) as any)["https://hasura.io/jwt/claims"][
"x-hasura-user-id"
],
) > 0
) {
return true;
}
} catch (e) {}
window.location.href = "/logout";
} else {
jwt = new URLSearchParams(window.location.search).get("jwt");
if (jwt) {
setCookie("jwt", jwt);
// set the jwt, and remove it from the url, then re-run this function
window.location.href = "/";
} else {
return false;
}
}
const jwtCookie = getCookie("jwt");
if (jwtCookie) return true;

// If JWT not set via cookie, check search params
const jwtSearchParams = new URLSearchParams(window.location.search).get(
"jwt",
);
if (!jwtSearchParams) return false;

// Remove JWT from URL, and re-run this function
setCookie("jwt", jwtSearchParams);
window.location.href = "/";
};

const Layout: React.FC<{
Expand Down
21 changes: 14 additions & 7 deletions editor.planx.uk/src/pages/FlowEditor/lib/store/user.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { User, UserTeams } from "@opensystemslab/planx-core/types";
import axios from "axios";
import { _client } from "client";
import { jwtDecode } from "jwt-decode";
import { Team } from "types";
import type { StateCreator } from "zustand";

Expand All @@ -10,7 +10,7 @@ export interface UserStore {
setUser: (user: User) => void;
getUser: () => User | undefined;
canUserEditTeam: (teamSlug: Team["slug"]) => boolean;
initUserStore: (jwt: string) => Promise<void>;
initUserStore: () => Promise<void>;
}

export const userStore: StateCreator<UserStore, [], [], UserStore> = (
Expand All @@ -31,15 +31,22 @@ export const userStore: StateCreator<UserStore, [], [], UserStore> = (
return user.isPlatformAdmin || user.teams.some(hasTeamEditorRole);
},

async initUserStore(jwt: string) {
async initUserStore() {
const { getUser, setUser } = get();

if (getUser()) return;

const id = (jwtDecode(jwt) as any)["sub"];
const user = await _client.user.getById(id);
if (!user) throw new Error(`Failed to get user with ID ${id}`);

const user = await getLoggedInUser();
setUser(user);
},
});

const getLoggedInUser = async () => {
const url = `${process.env.REACT_APP_API_URL}/user/me`;
try {
const response = await axios.get<User>(url, { withCredentials: true });
return response.data;
} catch (error) {
throw Error("Failed to fetch user matching JWT cookie");
}
};
4 changes: 2 additions & 2 deletions editor.planx.uk/src/routes/views/authenticated.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,13 @@ import AuthenticatedLayout from "../../pages/layout/AuthenticatedLayout";

/**
* View wrapper for all authenticated routes
* Parses JWT and inits user store
* Initialises user store
*/
export const authenticatedView = async () => {
const jwt = getCookie("jwt");
if (!jwt) return redirect("/login");

await useStore.getState().initUserStore(jwt);
await useStore.getState().initUserStore();

useStore.getState().setPreviewEnvironment("editor");

Expand Down
Loading