Skip to content

Commit

Permalink
Move auth flow to inside app
Browse files Browse the repository at this point in the history
  • Loading branch information
sergiodxa committed Dec 9, 2023
1 parent f8962cb commit fbd7236
Show file tree
Hide file tree
Showing 9 changed files with 280 additions and 74 deletions.
73 changes: 73 additions & 0 deletions app/modules/auth.server.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import type { User } from "./session.server";
import type { GitHub } from "../services/github.server";
import type { SessionStorage } from "@remix-run/cloudflare";

import { createCookieSessionStorage } from "@remix-run/cloudflare";
import { Authenticator } from "remix-auth";
import { GitHubStrategy } from "remix-auth-github";

interface Services {
gh: GitHub;
}

export class Auth {
protected authenticator: Authenticator<User>;
protected sessionStorage: SessionStorage;

public authenticate: Authenticator<User>["authenticate"];

constructor(
services: Services,
clientID: string,
clientSecret: string,
sessionSecret = "s3cr3t",
) {
this.sessionStorage = createCookieSessionStorage({
cookie: {
name: "sdx:auth",
path: "/",
maxAge: 60 * 60 * 24 * 365, // 1 year
httpOnly: true,
sameSite: "lax",
secure: process.env.NODE_ENV === "production",
secrets: [sessionSecret],
},
});

this.authenticator = new Authenticator<User>(this.sessionStorage, {
throwOnError: true,
sessionKey: "token",
});

this.authenticator.use(
new GitHubStrategy(
{
clientID,
clientSecret,
callbackURL: "/auth/github/callback",
},
async ({ profile }) => {
return {
displayName: profile._json.name,
username: profile._json.login,
email: profile._json.email ?? profile.emails?.at(0) ?? null,
avatar: profile._json.avatar_url,
githubId: profile._json.node_id,
isSponsor: await services.gh.isSponsoringMe(profile._json.node_id),
};
},
),
);

this.authenticate = this.authenticator.authenticate.bind(
this.authenticator,
);
}

public async clear(request: Request) {
let session = await this.sessionStorage.getSession(
request.headers.get("cookie"),
);
return this.sessionStorage.destroySession(session);
}
}
82 changes: 82 additions & 0 deletions app/modules/session.server.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import type { TypedSessionStorage } from "remix-utils/typed-session";

import { createWorkersKVSessionStorage, redirect } from "@remix-run/cloudflare";
import { createTypedSessionStorage } from "remix-utils/typed-session";
import { z } from "zod";

interface Services {
kv: KVNamespace;
}

export const UserSchema = z.object({
username: z.string(),
displayName: z.string(),
email: z.string().email().nullable(),
avatar: z.string().url(),
githubId: z.string().min(1),
isSponsor: z.boolean(),
});

export type User = z.infer<typeof UserSchema>;

export const SessionSchema = z.object({
user: UserSchema.optional(),
});

export class SessionStorage {
protected sessionStorage: TypedSessionStorage<typeof SessionSchema>;

public read: TypedSessionStorage<typeof SessionSchema>["getSession"];
public commit: TypedSessionStorage<typeof SessionSchema>["commitSession"];
public destroy: TypedSessionStorage<typeof SessionSchema>["destroySession"];

constructor(services: Services, secret = "s3cr3t") {
this.sessionStorage = createTypedSessionStorage({
sessionStorage: createWorkersKVSessionStorage({
kv: services.kv,
cookie: {
name: "sdx:session",
path: "/",
maxAge: 60 * 60 * 24 * 365, // 1 year
httpOnly: true,
sameSite: "lax",
secure: process.env.NODE_ENV === "production",
secrets: [secret],
},
}),
schema: SessionSchema,
});

this.read = this.sessionStorage.getSession;
this.commit = this.sessionStorage.commitSession;
this.destroy = this.sessionStorage.destroySession;
}

static async logout(services: Services, request: Request, secret = "s3cr3t") {
let sessionStorage = new SessionStorage(services, secret);
let session = await sessionStorage.read(request.headers.get("cookie"));
throw redirect("/", {
headers: { "set-cookie": await sessionStorage.destroy(session) },
});
}

static async readUser(
services: Services,
request: Request,
secret = "s3cr3t",
) {
let sessionStorage = new SessionStorage(services, secret);
let session = await sessionStorage.read(request.headers.get("cookie"));
return session.get("user");
}

static async requireUser(
services: Services,
request: Request,
secret = "s3cr3t",
) {
let maybeUser = await this.readUser(services, request, secret);
if (!maybeUser) throw redirect("/auth/login");
return maybeUser;
}
}
6 changes: 5 additions & 1 deletion app/root.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ import globalStylesUrl from "~/styles/global.css";
import tailwindUrl from "~/styles/tailwind.css";
import { removeTrailingSlash } from "~/utils/remove-trailing-slash";

import { SessionStorage } from "./modules/session.server";

export let links: LinksFunction = () => {
return [
{ rel: "preconnect", href: "https://static.cloudflareinsights.com" },
Expand Down Expand Up @@ -65,8 +67,10 @@ export function loader({ request, context }: LoaderFunctionArgs) {
return [{ title: t("header.title") }];
},
async user() {
return await context.services.auth.authenticator.isAuthenticated(
return await SessionStorage.readUser(
{ kv: context.kv.auth },
request,
context.env.COOKIE_SESSION_SECRET,
);
},
},
Expand Down
44 changes: 33 additions & 11 deletions app/routes/auth.$provider.callback.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,37 @@
import type { LoaderFunctionArgs } from "@remix-run/cloudflare";

import { redirect, type LoaderFunctionArgs } from "@remix-run/cloudflare";
import { z } from "zod";

export function loader(_: LoaderFunctionArgs) {
return _.context.time("routes/auth.$provider.callback#loader", async () => {
let provider = z.enum(["github"]).parse(_.params.provider);
import { Auth } from "~/modules/auth.server";
import { SessionStorage } from "~/modules/session.server";
import { GitHub } from "~/services/github.server";

export async function loader(_: LoaderFunctionArgs) {
let provider = z.enum(["github"]).parse(_.params.provider);

let gh = new GitHub(_.context.env.GH_APP_ID, _.context.env.GH_APP_PEM);

let auth = new Auth(
{ gh },
_.context.env.GITHUB_CLIENT_ID,
_.context.env.GITHUB_CLIENT_SECRET,
);

let user = await auth.authenticate(provider, _.request);

if (!user) throw redirect("/auth/login");

let sessionStorage = new SessionStorage(
{ kv: _.context.kv.auth },
_.context.env.COOKIE_SESSION_SECRET,
);

let session = await sessionStorage.read(_.request.headers.get("cookie"));
session.set("user", user);

let headers = new Headers();

headers.append("set-cookie", await sessionStorage.commit(session));
headers.append("set-cookie", await auth.clear(_.request));

return await _.context.services.auth.authenticator.authenticate(
provider,
_.request,
{ successRedirect: "/", failureRedirect: "/auth/login" },
);
});
throw redirect("/", { headers });
}
95 changes: 50 additions & 45 deletions app/routes/auth.login.tsx
Original file line number Diff line number Diff line change
@@ -1,28 +1,35 @@
import type { DataFunctionArgs } from "@remix-run/cloudflare";

import { ExclamationTriangleIcon } from "@heroicons/react/20/solid";
import { json } from "@remix-run/cloudflare";
import { Form, useLoaderData } from "@remix-run/react";
import { json, redirect } from "@remix-run/cloudflare";
import { Form } from "@remix-run/react";

import { useT } from "~/helpers/use-i18n.hook";
import { Auth } from "~/modules/auth.server";
import { SessionStorage } from "~/modules/session.server";
import { GitHub } from "~/services/github.server";

export function loader(_: DataFunctionArgs) {
return _.context.time("routes/login#loader", async () => {
let session = await _.context.services.auth.sessionStorage.getSession(
_.request.headers.get("Cookie"),
);
let error = session.get("auth:error");
return json({ error });
});
export async function loader(_: DataFunctionArgs) {
let user = await SessionStorage.readUser(
{ kv: _.context.kv.auth },
_.request,
_.context.env.COOKIE_SESSION_SECRET,
);
if (!user) return json(null);
throw redirect("/");
}

export function action(_: DataFunctionArgs) {
return _.context.time("routes/login#action", async () => {
return await _.context.services.auth.authenticator.authenticate(
"github",
_.request,
{ successRedirect: "/", failureRedirect: "/login," },
);
export async function action(_: DataFunctionArgs) {
let gh = new GitHub(_.context.env.GH_APP_ID, _.context.env.GH_APP_PEM);

let auth = new Auth(
{ gh },
_.context.env.GITHUB_CLIENT_ID,
_.context.env.GITHUB_CLIENT_SECRET,
);

return await auth.authenticate("github", _.request, {
successRedirect: "/auth/github/callback",
failureRedirect: "/auth/login",
});
}

Expand All @@ -46,36 +53,34 @@ export default function Component() {
>
{t("login.github")}
</button>

<Alert />
</Form>
);
}

function Alert() {
let { error } = useLoaderData<typeof loader>();
let t = useT("translation");
// function Alert() {
// let { error } = useLoaderData<typeof loader>();
// let t = useT("translation");

if (!error) return null;
// if (!error) return null;

return (
<div className="w-full rounded-md bg-red-50 p-4">
<div className="flex">
<div className="flex-shrink-0">
<ExclamationTriangleIcon
className="h-5 w-5 text-red-400"
aria-hidden="true"
/>
</div>
<div className="ml-3">
<h3 className="text-sm font-medium text-red-800">
{t("login.error.title")}
</h3>
<div className="mt-2 text-sm text-red-700">
<p>{t("login.error.description")}</p>
</div>
</div>
</div>
</div>
);
}
// return (
// <div className="w-full rounded-md bg-red-50 p-4">
// <div className="flex">
// <div className="flex-shrink-0">
// <ExclamationTriangleIcon
// className="h-5 w-5 text-red-400"
// aria-hidden="true"
// />
// </div>
// <div className="ml-3">
// <h3 className="text-sm font-medium text-red-800">
// {t("login.error.title")}
// </h3>
// <div className="mt-2 text-sm text-red-700">
// <p>{t("login.error.description")}</p>
// </div>
// </div>
// </div>
// </div>
// );
// }
35 changes: 20 additions & 15 deletions app/routes/auth.logout.tsx
Original file line number Diff line number Diff line change
@@ -1,24 +1,29 @@
import type { DataFunctionArgs } from "@remix-run/cloudflare";

import {
json,
type ActionFunctionArgs,
type LoaderFunctionArgs,
} from "@remix-run/cloudflare";
import { Form } from "@remix-run/react";

import { useT } from "~/helpers/use-i18n.hook";
import { SessionStorage } from "~/modules/session.server";

export async function loader(_: LoaderFunctionArgs) {
await SessionStorage.requireUser(
{ kv: _.context.kv.auth },
_.request,
_.context.env.COOKIE_SESSION_SECRET,
);

export async function loader(_: DataFunctionArgs) {
return _.context.time("routes/auth.logout#loader", async () => {
return await _.context.services.auth.authenticator.isAuthenticated(
_.request,
{ successRedirect: "/" },
);
});
return json(null);
}

export async function action(_: DataFunctionArgs) {
return _.context.time("routes/login#action", async () => {
return await _.context.services.auth.authenticator.logout(_.request, {
redirectTo: "/",
});
});
export async function action(_: ActionFunctionArgs) {
return await SessionStorage.logout(
{ kv: _.context.kv.auth },
_.request,
_.context.env.COOKIE_SESSION_SECRET,
);
}

export default function Component() {
Expand Down
Loading

0 comments on commit fbd7236

Please sign in to comment.