Using cookies
Tags: react, cloud
Published: 2022-08-14 17:14:00

In the previous article we managed to sign a user in with GitHub. Now we have to _remember_ the signed-in user. There was also a `state` parameter that we glossed over that was passed back and forth between our server and GitHub to make sure that the sign-in request was indeed initiated by us, and not by a malicious third party. `state` is, in effect, a cross-site request forgery prevention token. We'll just generate a random ID and _remember_ it. Cookies are the most common way to remember something in a web application.

As we discussed before, Rakkas relies on HatTip for handling HTTP so we will use the [`@hattip/cookie`](https://github.com/hattipjs/hattip/tree/main/packages/middleware/cookie) package to manage cookies:

```bash
npm install -S @hattip/cookie
```

Then we will add the cookie middleware to our `entry-hattip.ts`. We'll use the `crypto.randomUUID()` function to generate our `state` token but `crypto` is not globally available in Node. Luckily it is still available in the `crypto` package under the name `webcrypto` so we can easily polyifll it:

```tsx
import { createRequestHandler } from "rakkasjs";
import { cookie } from "@hattip/cookie";

declare module "rakkasjs" {
	interface ServerSideLocals {
		postStore: KVNamespace;
	}
}

export default createRequestHandler({
	middleware: {
		beforePages: [
			cookie(),
			async (ctx) => {
				if (import.meta.env.DEV) {
					const { postStore } = await import("./kv-mock");
					ctx.locals.postStore = postStore;

					// Polyfill crypto
					if (typeof crypto === "undefined") {
						const { webcrypto } = await import("crypto");
						globalThis.crypto = webcrypto as any;
					}
				} else {
					ctx.locals.postStore = (ctx.platform as any).env.KV_POSTS;
				}

				// We'll add more stuff here later
			},
		],
	},
});
```

The cookie middleware makes things like `ctx.cookie` and `ctx.setCookie` available in our server-side code. So now we can generate our random state token and put it in a cookie at the spot we marked with "We'll add more stuff here later" comment:

```tsx
if (!ctx.cookie.state) {
	const randomToken = crypto.randomUUID();
	ctx.setCookie("state", randomToken, {
		httpOnly: true,
		secure: import.meta.env.PROD,
		sameSite: "strict",
		maxAge: 60 * 60,
	});

	// To make it immediately available,
	// We'll store it here too.
	ctx.cookie.state = randomToken;
}
```

Now we can use the cookie value instead of our `12345` placeholder in `src/routes/layout.tsx`:

```tsx
const {
	data: { clientId, state },
} = useServerSideQuery((ctx) => ({
	clientId: process.env.GITHUB_CLIENT_ID,
	state: ctx.cookie.state,
}));
```

...and in the login page (`src/routes/login.page.tsx`):

```tsx
const { data: userData } = useServerSideQuery(async (ctx) => {
	if (code && state === ctx.cookie.state) {
		// ... rest of the code
	}
});
```

Now if you visit our main page and click "Sign in with GitHub", the whole sign-in routine should still work, but this time with a proper random `state` token instead of the placeholder.

## Remembering the signed-in user

We can use another cookie to store the GitHub access token. The only thing our login page has to do is to get the token and store it in a cookie. Then we can simply redirect to the main page again. Rakkas offers several ways to redirect but, amazingly, [some browsers still have problems setting cookies on redirections](https://stackoverflow.com/questions/4694089/sending-browser-cookies-during-a-302-redirect). So we will use HTML [meta refresh](https://en.wikipedia.org/wiki/Meta_refresh) for our redirection.

To be able to set a cookie from a page, we export a [`headers` function](https://rakkasjs.org/guide/status-and-headers). So we will have to refactor our code a little. This is how our `login.page.tsx` gonna look like with this implemented:

```tsx
import { Head, PageProps, HeadersFunction } from "rakkasjs";

export default function LoginPage({ url }: PageProps) {
	const error = url.searchParams.get("error");

	if (error) {
		return <div>Error: {error}</div>;
	}

	return (
		<div>
			<Head>
				{/* Redirect immediately */}
				<meta httpEquiv="refresh" content="0; url=/" />
			</Head>
			<p>Redirecting...</p>
		</div>
	);
}

export const headers: HeadersFunction = async ({
	url,
	requestContext: ctx,
}) => {
	if (url.searchParams.get("error")) {
		return { status: 403 };
	}

	const code = url.searchParams.get("code");
	const state = url.searchParams.get("state");

	if (code && state === ctx.cookie.state) {
		const { access_token: token } = await fetch(
			"https://github.com/login/oauth/access_token" +
				`?client_id=${process.env.GITHUB_CLIENT_ID}` +
				`&client_secret=${process.env.GITHUB_CLIENT_SECRET}` +
				`&code=${code}`,
			{
				method: "POST",
				headers: { Accept: "application/json" },
			}
		).then((r) => r.json<{ access_token: string }>());

		if (token) {
			ctx.setCookie("token", token, {
				httpOnly: true,
				secure: import.meta.env.PROD,
				sameSite: "strict",
				maxAge: 60 * 60,
			});

			return {
				// We won't be setting any headers,
				// setCookie will do it for us,
				// so an empty object is fine.
			};
		}
	}

	// Login failed for some reason
	// We'll redirect to set the `error` parameter
	return {
		status: 302,
		headers: {
			Location: new URL(`/login?error=Login%20failed`, url).href,
		},
	};
};
```

Now when we sign in, we're redirected to the main page and the GitHub access token is stored in a cookie. We can now use the token to fetch the user's profile from GitHub on every request in `entry-hattip.ts` and make it available in `ctx.locals.user`. First, let's define our types:

```ts
interface GitHubUser {
	// Just the bits we need
	login: string;
	name: string;
	avatar_url: string;
}

declare module "rakkasjs" {
	interface ServerSideLocals {
		postStore: KVNamespace;
		user?: GitHubUser;
	}
}
```

And then put the user's profile in `ctx.locals.user` (right after the `state` cookie handling code):

```ts
if (ctx.cookie.token) {
	const user: GitHubUser = await fetch("https://api.github.com/user", {
		headers: {
			Authorization: `token ${ctx.cookie.token}`,
		},
	}).then((r) => r.json());

	ctx.locals.user = user;
}
```

Finally, we can read this data in our main layout to show the login status:

```tsx
import { LayoutProps, useServerSideQuery } from "rakkasjs";

export default function MainLayout({ children }: LayoutProps) {
	const {
		data: { clientId, state, user },
	} = useServerSideQuery((ctx) => ({
		clientId: process.env.GITHUB_CLIENT_ID,
		state: ctx.cookie.state,
		user: ctx.locals.user,
	}));

	return (
		<>
			<header>
				<strong>uBlog</strong>
				<span style={{ float: "right" }}>
					{user ? (
						<span>
							<img src={user.avatar_url} width={32} />
							&nbsp;
							{user.name}
						</span>
					) : (
						<a
							href={
								"https://github.com/login/oauth/authorize" +
								`?client_id=${clientId}` +
								`&state=${state}`
							}
						>
							Sign in with GitGub
						</a>
					)}
				</span>
				<hr />
			</header>
			{children}
		</>
	);
}
```

Yes, yes, ugly. We'll get there. Let's update our create form action handler in `index.page.tsx` to set the `author` metadata in the created post. We should also disallow creating posts if the user is not logged in:

```tsx
export const action: ActionHandler = async (ctx) => {
	if (!ctx.requestContext.locals.user) {
		return { data: { error: "You must be signed in to post." } };
	}

	// Retrieve the form data
	const data = await ctx.requestContext.request.formData();
	const content = data.get("content");

	// Do some validation
	if (!content) {
		return { data: { error: "Content is required" } };
	} else if (typeof content !== "string") {
		// It could be a file upload!
		return { data: { error: "Content must be a string" } };
	} else if (content.length > 280) {
		return {
			data: {
				error: "Content must be less than 280 characters",
				content, // Echo back the content to refill the form
			},
		};
	}

	await ctx.requestContext.locals.postStore.put(generateKey(), content, {
		metadata: {
			// We don't have login/signup yet,
			// so we'll just make up a user name
			author: ctx.requestContext.locals.user.login,
			postedAt: new Date().toISOString(),
		},
	});

	return { data: { error: null } };
};
```

Cool, we can now tweet under our own user name!

There's no point in showing the create post form if the user is not logged in, since we're not gonna allow it anyway. Let's update our page component to handle that too:

```tsx
export default function HomePage({ actionData }: PageProps) {
	const {
		data: { posts, user },
	} = useServerSideQuery(async (ctx) => {
		const list = await ctx.locals.postStore.list<{
			author: string;
			postedAt: string;
		}>();

		const posts = await Promise.all(
			list.keys.map((key) =>
				ctx.locals.postStore
					.get(key.name)
					.then((data) => ({ key, content: data }))
			)
		);

		return { posts, user: ctx.locals.user };
	});

	return (
		<main>
			<h1>Posts</h1>
			<ul>
				{posts.map((post) => (
					<li key={post.key.name}>
						<div>{post.content}</div>
						<div>
							<i>{post.key.metadata?.author ?? "Unknown author"}</i>
							&nbsp;
							<span>
								{post.key.metadata?.postedAt
									? new Date(post.key.metadata.postedAt).toLocaleString()
									: "Unknown date"}
							</span>
						</div>
						<hr />
					</li>
				))}
			</ul>

			{user && (
				<form method="POST">
					<p>
						<textarea
							name="content"
							rows={4}
							defaultValue={actionData?.content}
						/>
					</p>

					{actionData?.error && <p>{actionData.error}</p>}

					<button type="submit">Submit</button>
				</form>
			)}
		</main>
	);
}
```

## Sign out

We need one last feature: the ability to sign out. We will add a "sign out" button that will post to a `/logout` API route which sign the user out by deleting the access token cookie. The button (and the form) will look like this:

```tsx
<form method="POST" action="/logout">
	<button type="submit">Sign out</button>
</form>
```

Now we'll add an API route to handler the action. Rakkas [API routes](https://rakkasjs.org/guide/api-routes) are modules named `<path>.api.ts` (or `.js`). The export request handling functions which have the same name as the HTTP method they handle, but in lowercase. For example, the `POST` handler will be named `post`. `DELETE` handlers, however, are named `del` because `delete` is a reserved word in JavaScript. According to this, we're supposed to name our logout route `src/routes/logout.api.ts` and it will look simply like this:

```ts
import { RequestContext } from "rakkasjs";

export function post(ctx: RequestContext) {
	ctx.deleteCookie("token");
	return new Response(null, {
		status: 302,
		headers: {
			Location: new URL("/", ctx.request.url).href,
		},
	});
}
```

And now we will be able to sign out!

## Deploying

Now that we've added all the features we need, we can deploy our application. We'll test locally with Miniflare first but there is one more thing to take care of: GitHub API requires a user agent for all requests. It was working fine so far, because Rakkas uses `node-fetch` to make requests and `node-fetch` automatically sets the user agent. It's not the case for Miniflare or Cloudflare Workers. So we'll have to set it ourselves in `entry-hattip.ts`:

```ts
const user: GitHubUser = await fetch("https://api.github.com/user", {
	headers: {
		Authorization: `token ${ctx.cookie.token}`,
		// Put your own GitHub name here
		"User-Agent": "uBlog by cyco130",
	},
}).then((r) => r.json());
```

Add the same header to the request in `login.page.tsx`'s `headers` function. Now we're set:

```bash
npm run build # Build the application
npm run local -- --port 5173
```

We told `miniflare` to use port 5173, because that's the address we gave GitHub while registering our app. If all goes well, our app should run on Miniflare too!

We're almost ready to deploy. But first, we have to change our GitHub app's callback URL to point at our deployment URL (should be something ending with `workers.dev`). Actually a better idea is to register a second app and keep the first one for development. Register your app, generate a client key and add a `[vars]` to your `wrangler.toml` like this:

```toml
[vars]
GITHUB_CLIENT_ID = "<your client ID>"
GITHUB_CLIENT_SECRET = "<your client secret>"
```

Now we're ready to deploy with `npm run deploy`! If all goes well, your app will be deployed to Cloudflare Workers and you should be able to sign in with GitHub, create posts with your username, and sign out. You can share it with your friends to test if it works for them too.

## Small bugs

If you played around enough with it, you may have noticed a small bug: If the Cloudflare edge that is running your app happens to be on a different time zone than you are, the server will render a different date than the client. The same will happen if your browser's locale is different than the server's. The easiest way to fix this is to always render the date on the client. Rakkas has a [`ClientOnly`](https://rakkasjs.org/guide/client-rendering) component that does exactly that. We'll fix it and redeploy:

```tsx
<ClientOnly fallback={null}>
	{new Date(post.key.metadata.postedAt).toLocaleString()}
</ClientOnly>
```

Also, you may occasionally find that sometimes new tweets don't show up in the list unless you refresh your browser a few times. That's because Cloudflare Workers KV is an _eventually consistent_ store. So, occasionally, your changes may not be immediately visible. It may actually take up to a minute to fully synchronize. This is part of the nature of the store we're using and also happens quite rarely so we'll leave it alone for now.

## What's next?

In the next article, we'll finish our do some styling and do the finishing touches. Then we'll discuss some ideas to take the project further.

> You can find the progress up to this point on [GitHub](https://github.com/rakkasjs/ublog/tree/article-5).