Skip to content
This repository has been archived by the owner on Jul 16, 2024. It is now read-only.

Commit

Permalink
Merge pull request #4 from zmillman/remix-auth
Browse files Browse the repository at this point in the history
Add authentication
  • Loading branch information
zmillman authored May 5, 2024
2 parents e209d5e + 5fb39f3 commit fb85265
Show file tree
Hide file tree
Showing 15 changed files with 378 additions and 15 deletions.
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,4 @@ cdk.out

# App build artifacts
build
.env.local
24 changes: 19 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
# Remix.run Enterprise Boilerplate

Inspired by https://github.com/Blazity/next-enterprise
Inspired by [Blazity/next-enterprise](https://github.com/Blazity/next-enterprise), this sets you up with a boilerplate app that's ready for enterprise app development. This template pre-implements a skeleton app with support for Google Oauth login with users segmented by their organization's email domain.

## Features

With this template, you get a full app stack:

- [Remix](https://remix.run/) on [Express](https://expressjs.com/) - Fast and developer-friendly
- [Joy UI](https://mui.com/joy-ui/getting-started/) - MUI's customizable component library
- [Authjs](https://authjs.dev/) - Integration with all the big auth providers
- [Remix Auth](https://github.com/sergiodxa/remix-auth) - Authentication through [several providers](https://github.com/sergiodxa/remix-auth/discussions/111) ([Auth.js](https://authjs.dev/) would be preferred but doesn't integrate with Remix...[yet](https://authjs.dev/getting-started/integrations))
- [Prisma](https://www.prisma.io/) - Easy persistence management
- [Postgres](https://www.postgresql.org/) - The most popular SQL database
- TBD - For running async jobs
Expand All @@ -31,19 +31,33 @@ Production:

To get started with this boilerplate, follow these steps:

1. Fork & clone the repository
1\. Fork & clone the repository

```sh
git clone https://github.com/{your_username}/remix-enterprise
```

2. Install the dependencies
2\. Install the dependencies

```sh
npm install
```

3. Run the dev server and open [localhost:3000](http://localhost:3000/)
3\. Follow the steps on [the Google documentation](https://developers.google.com/identity/protocols/oauth2/web-server#creatingcred) to configure a new application and get a client ID and secret. (The callback url is `http://localhost:3000/auth/google/callback`)

```
# .env.local
GOOGLE_CLIENT_ID=<your client id>
GOOGLE_CLIENT_SECRET=<your client secret>
```

Then configure a secret for encrypting session cookies:

```sh
echo "AUTH_SECRET=`openssl rand -base64 33`" >> .env.local
```

4\. Run the dev server and open [localhost:3000](http://localhost:3000/)

```sh
npm run dev
Expand Down
38 changes: 38 additions & 0 deletions app/components/AppShell.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { Box, Button, Flex } from "@radix-ui/themes";
import { Form, Link } from "@remix-run/react";

interface AppShellProps {
user?: {
email: string;
name: string;
};
}

/**
* Wraps content with a topbar for signing in / out
*/
export default function AppShell(
props: React.PropsWithChildren<AppShellProps>,
) {
return (
<Box>
<Flex justify="between">
<Box>
<Link to="/">Home</Link>
</Box>
<Box>
{props.user ? (
<Form action="/logout" method="POST">
<Button type="submit">Sign out</Button>
</Form>
) : (
<Button asChild>
<Link to="/login">Sign in</Link>
</Button>
)}
</Box>
</Flex>
<Box>{props.children}</Box>
</Box>
);
}
34 changes: 27 additions & 7 deletions app/root.tsx
Original file line number Diff line number Diff line change
@@ -1,17 +1,34 @@
import { Links, Meta, MetaFunction, Outlet, Scripts } from "@remix-run/react";
import type { LinksFunction } from "@remix-run/node";
import {
json,
Links,
Meta,
type MetaFunction,
Outlet,
Scripts,
useLoaderData,
} from "@remix-run/react";
import type { LinksFunction, LoaderFunctionArgs } from "@remix-run/node";
import "@radix-ui/themes/styles.css";
import { Button, Theme } from "@radix-ui/themes";
import { Container, Theme } from "@radix-ui/themes";
import AppShell from "./components/AppShell";
import { getSession } from "./services/session.server";

export const links: LinksFunction = () => {
return [{ rel: "icon", href: "/favicon-32.png" }];
};

export const meta: MetaFunction = () => {
return [{title: "Remix Enterprise"}]
}
return [{ title: "Remix Enterprise" }];
};

export const loader = async ({ request }: LoaderFunctionArgs) => {
const session = await getSession(request.headers.get("Cookie"));
return json({ user: session.data.user });
};

export default function App() {
const data = useLoaderData<typeof loader>();

return (
<html>
<head>
Expand All @@ -21,8 +38,11 @@ export default function App() {
</head>
<body>
<Theme>
<Button>Hey world 👋</Button>
<Outlet />
<AppShell user={data.user}>
<Container size="1" my="8">
<Outlet />
</Container>
</AppShell>

<Scripts />
</Theme>
Expand Down
27 changes: 27 additions & 0 deletions app/routes/_index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { Code, Heading, Text } from "@radix-ui/themes";
import { type LoaderFunctionArgs } from "@remix-run/node";
import { json, Link, useLoaderData } from "@remix-run/react";
import { getSession } from "../services/session.server";

export const loader = async ({ request }: LoaderFunctionArgs) => {
const session = await getSession(request.headers.get("Cookie"));
return json({ session: session.data });
};

export default function HomePage() {
const data = useLoaderData<typeof loader>();

return (
<>
<Heading mb="4">Welcome to the app</Heading>
<Text mb="4" as="p">
This is an example demonstrating the Remix template. You can only view
the <Link to="/dashboard">Dashboard</Link> if you're signed in.
</Text>
<Heading size="3">Session</Heading>
<pre>
<Code>{JSON.stringify(data.session, null, 2)}</Code>
</pre>
</>
);
}
9 changes: 9 additions & 0 deletions app/routes/auth.google.callback.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { LoaderFunctionArgs } from "@remix-run/node";
import { authenticator } from "../services/auth.server";

export const loader = ({ request }: LoaderFunctionArgs) => {
return authenticator.authenticate("google", request, {
successRedirect: "/dashboard",
failureRedirect: "/login",
});
};
8 changes: 8 additions & 0 deletions app/routes/auth.google.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { ActionFunctionArgs, redirect } from "@remix-run/node";
import { authenticator } from "../services/auth.server";

export const loader = () => redirect("/login");

export const action = ({ request }: ActionFunctionArgs) => {
return authenticator.authenticate("google", request);
};
24 changes: 24 additions & 0 deletions app/routes/dashboard.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { Box, Heading, Text } from "@radix-ui/themes";
import { json, LoaderFunctionArgs } from "@remix-run/node";
import { useLoaderData } from "@remix-run/react";
import { authenticatedUser } from "../services/auth.server";

export const loader = async ({ request }: LoaderFunctionArgs) => {
return json({ user: await authenticatedUser(request) });
};

export default function DashboardPage() {
const data = useLoaderData<typeof loader>();

return (
<Box>
<Heading mb="4">Dashboard</Heading>
<Text as="p" mb="4">
Welcome to the dashboard, {data.user.name}!
</Text>
<Text as="p" mb="4">
You're only able to see this page if you're signed in.
</Text>
</Box>
);
}
52 changes: 52 additions & 0 deletions app/routes/login.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { Box, Button, Callout, Card, Heading } from "@radix-ui/themes";
import { LoaderFunctionArgs } from "@remix-run/node";
import { Form, json, redirect, useLoaderData } from "@remix-run/react";
import { authenticator } from "../services/auth.server";
import { InfoCircledIcon } from "@radix-ui/react-icons";
import { commitSession, getSession } from "../services/session.server";

export async function loader({ request }: LoaderFunctionArgs) {
// If the user is already authenticated redirect to /dashboard directly
const user = await authenticator.isAuthenticated(request, {});

if (user) {
return redirect("/dashboard");
} else {
const session = await getSession(request.headers.get("Cookie"));
const authError = session.get(authenticator.sessionErrorKey);

return json(
{ authErrorMessage: authError?.message },
{
headers: {
"Set-Cookie": await commitSession(session), // clear flash message from the cookie
},
},
);
}
}

export default function LoginPage() {
const data = useLoaderData<typeof loader>();

return (
<Card>
<Box>
<Heading mb="4">Sign in</Heading>
{data.authErrorMessage ? (
<Callout.Root color="red" mb="2">
<Callout.Icon>
<InfoCircledIcon />
</Callout.Icon>
<Callout.Text>{data.authErrorMessage}</Callout.Text>
</Callout.Root>
) : (
<></>
)}
<Form action="/auth/google" method="POST">
<Button type="submit">Sign in with Google</Button>
</Form>
</Box>
</Card>
);
}
6 changes: 6 additions & 0 deletions app/routes/logout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { ActionFunctionArgs } from "@remix-run/node";
import { authenticator } from "../services/auth.server";

export async function action({ request }: ActionFunctionArgs) {
await authenticator.logout(request, { redirectTo: "/login" });
}
65 changes: 65 additions & 0 deletions app/services/auth.server.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import { Authenticator } from "remix-auth";
import { commitSession, getSession, sessionStorage } from "./session.server";
import { GoogleStrategy } from "remix-auth-google";
import { redirect } from "@remix-run/server-runtime";

// TODO: Make this a persisted schema
interface User {
name: string;
email: string;
}

// Create an instance of the authenticator, pass a generic with what
// strategies will return and will store in the session
export const authenticator = new Authenticator<User>(sessionStorage, {
sessionErrorKey: "auth-error",
throwOnError: true,
});

const googleClientId = process.env.GOOGLE_CLIENT_ID;
const googleClientSecret = process.env.GOOGLE_CLIENT_SECRET;

if (!googleClientId) {
throw new Error("You must provide a GOOGLE_CLIENT_ID");
}
if (!googleClientSecret) {
throw new Error("You must provide a GOOGLE_CLIENT_SECRET");
}

const googleStrategy = new GoogleStrategy(
{
clientID: googleClientId,
clientSecret: googleClientSecret,
callbackURL: "http://localhost:3000/auth/google/callback",
prompt: "select_account", // always ask the user to pick their account
},
// eslint-disable-next-line @typescript-eslint/no-unused-vars
async ({ accessToken, refreshToken, extraParams, profile }) => {
// Get the user data from your DB or API using the tokens and profile
// return User.findOrCreate({ email: profile.emails[0].value })

return { name: profile.displayName, email: profile.emails[0].value };
},
);

authenticator.use(googleStrategy);

/**
* Get the authenticated user, or redirect to `/login` if they're not signed in
*/
export const authenticatedUser = async (request: Request) => {
const user = await authenticator.isAuthenticated(request);

if (user) {
// TODO: load user data from the database instead of storing in the session
return user;
} else {
const session = await getSession(request.headers.get("Cookie"));
session.flash(authenticator.sessionErrorKey, {
message: "You must be signed in to view that page",
});
throw redirect("/login", {
headers: { "Set-Cookie": await commitSession(session) }, // persist flash message
});
}
};
23 changes: 23 additions & 0 deletions app/services/session.server.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
// Copied from https://github.com/sergiodxa/remix-auth/blob/main/README.md

import { createCookieSessionStorage } from "@remix-run/node";

const authSecret = process.env.AUTH_SECRET;
if (!authSecret) {
throw new Error("You must provide a AUTH_SECRET");
}

// export the whole sessionStorage object
export const sessionStorage = createCookieSessionStorage({
cookie: {
name: "_session", // use any name you want here
sameSite: "lax", // this helps with CSRF
path: "/", // remember to add this so the cookie will work in all routes
httpOnly: true, // for security reasons, make this cookie http only
secrets: [authSecret], // token to encrypt content
secure: process.env.NODE_ENV === "production", // enable this in prod only
},
});

// you can also export the methods individually for your own usage
export const { getSession, commitSession, destroySession } = sessionStorage;
Loading

0 comments on commit fb85265

Please sign in to comment.