Fatih Aygün
Posted on August 14, 2022
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
package to manage cookies:
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:
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:
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
:
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
):
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. So we will use HTML meta refresh for our redirection.
To be able to set a cookie from a page, we export a headers
function. So we will have to refactor our code a little. This is how our login.page.tsx
gonna look like with this implemented:
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:
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):
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:
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} />
{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:
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:
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>
<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:
<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 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:
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
:
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:
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:
[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
component that does exactly that. We'll fix it and redeploy:
<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.
Posted on August 14, 2022
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.