DEV Community

Fatih Aygün
Fatih Aygün

Posted on

Using cookies

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
Enter fullscreen mode Exit fullscreen mode

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
            },
        ],
    },
});
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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,
}));
Enter fullscreen mode Exit fullscreen mode

...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
    }
});
Enter fullscreen mode Exit fullscreen mode

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,
        },
    };
};
Enter fullscreen mode Exit fullscreen mode

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;
    }
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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} />
                            &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}
        </>
    );
}
Enter fullscreen mode Exit fullscreen mode

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 } };
};
Enter fullscreen mode Exit fullscreen mode

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>
                            &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>
    );
}
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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,
        },
    });
}
Enter fullscreen mode Exit fullscreen mode

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());
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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>"
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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.

Oldest comments (0)