Skip to content

Commit

Permalink
feat(examples): add React Router (a.k.a. Remix) (#16)
Browse files Browse the repository at this point in the history
* initial scaffold from `create-react-router`

* remove docker things

* remove default css & tailwind

* delete default files from CRR template

* implement auth

* update README

* delete default css

* format code
  • Loading branch information
samialdury authored Dec 10, 2024
1 parent 249c5cd commit ff5d7b5
Show file tree
Hide file tree
Showing 15 changed files with 306 additions and 0 deletions.
Binary file modified bun.lockb
Binary file not shown.
6 changes: 6 additions & 0 deletions examples/client/react-router/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
.DS_Store
/node_modules/

# React Router
/.react-router/
/build/
5 changes: 5 additions & 0 deletions examples/client/react-router/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# OpenAuth React Router Client

The files to note are
- `app/auth.ts` - creates the client that is used to interact with the auth server, along with code that runs to verify access tokens, refresh them if out of date, and redirect the user to the auth server if they are not logged in
- `app/routes/auth/callback.ts` - the callback endpoint that receives the auth code and exchanges it for an access/refresh token
94 changes: 94 additions & 0 deletions examples/client/react-router/app/auth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import { createClient } from "@openauthjs/openauth";
import { createCookie } from "react-router";
import { subjects } from "../../../subjects";

export { subjects }

export const client = createClient({
clientID: "react-router",
issuer: "http://localhost:3000",
});

const refreshTokenCookie = createCookie("refresh_token", {
httpOnly: true,
sameSite: "strict",
path: "/",
maxAge: 34_560_000,
})

const accessTokenCookie = createCookie("access_token", {
httpOnly: true,
sameSite: "strict",
path: "/",
maxAge: 34_560_000,
})

export async function setTokens(access: string, refresh: string, headers?: Headers) {
headers ??= new Headers()
headers.append('Set-Cookie', await refreshTokenCookie.serialize(refresh))
headers.append('Set-Cookie', await accessTokenCookie.serialize(access))
return headers
}

export async function clearTokens(headers?: Headers) {
headers ??= new Headers()
headers.append('Set-Cookie', await refreshTokenCookie.serialize("", { maxAge: 0 }))
headers.append('Set-Cookie', await accessTokenCookie.serialize("", { maxAge: 0 }))
return headers
}

export async function login(request: Request) {
const url = new URL(request.url);
return Response.redirect(
client.authorize(url.origin + "/callback", "code"),
302
);
}

export async function logout() {
const headers = await clearTokens()
headers.set('Location', '/')
return new Response("/", {
status: 302,
headers
})
}

/**
* Checks if the user is authenticated.
* If so, returns the subject along with updated tokens, otherwise `null`.
*/
export async function tryAuth(request: Request) {
const cookieHeader = request.headers.get("Cookie");
try {
const accessToken = await accessTokenCookie.parse(cookieHeader);
if (!accessToken) return null
const refreshToken = await refreshTokenCookie.parse(cookieHeader);
const verified = await client.verify(subjects, accessToken, {
refresh: refreshToken,
});
const headers = new Headers();
if (verified.tokens) {
setTokens(verified.tokens.access, verified.tokens.refresh, headers);
}
return {
headers,
subject: verified.subject
};
} catch {
return null
};
}

/**
* Requires the user to be authenticated.
* If so, returns the subject along with updated tokens, otherwise throws a redirect to login.
*/
export async function requireAuth(request: Request) {
const auth = await tryAuth(request)
if (!auth) {
throw login(request)
}
return auth
}

60 changes: 60 additions & 0 deletions examples/client/react-router/app/root.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import {
isRouteErrorResponse,
Links,
Meta,
Outlet,
Scripts,
ScrollRestoration,
} from "react-router";
import type { Route } from "./+types/root";

export function Layout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<head>
<meta charSet="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<Meta />
<Links />
</head>
<body>
{children}
<ScrollRestoration />
<Scripts />
</body>
</html>
);
}

export default function App() {
return <Outlet />;
}

export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) {
let message = "Oops!";
let details = "An unexpected error occurred.";
let stack: string | undefined;

if (isRouteErrorResponse(error)) {
message = error.status === 404 ? "404" : "Error";
details =
error.status === 404
? "The requested page could not be found."
: error.statusText || details;
} else if (import.meta.env.DEV && error && error instanceof Error) {
details = error.message;
stack = error.stack;
}

return (
<main>
<h1>{message}</h1>
<p>{details}</p>
{stack && (
<pre>
<code>{stack}</code>
</pre>
)}
</main>
);
}
8 changes: 8 additions & 0 deletions examples/client/react-router/app/routes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { type RouteConfig, index, route } from "@react-router/dev/routes";

export default [
index("routes/home.tsx"),
route('callback', 'routes/auth/callback.ts'),
route('login', 'routes/auth/login.ts'),
route('logout', 'routes/auth/logout.ts')
] satisfies RouteConfig;
20 changes: 20 additions & 0 deletions examples/client/react-router/app/routes/auth/callback.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { client, setTokens } from "../../auth";
import type { Route } from "./+types/callback";

export async function loader({ request }: Route.LoaderArgs) {
const url = new URL(request.url)
const code = url.searchParams.get("code")
try {
const tokens = await client.exchange(code!, url.origin + "/callback");
const headers = await setTokens(tokens.access, tokens.refresh);
headers.append("Location", "/");
return new Response(null, {
headers,
status: 302,
})
} catch (e) {
return Response.json(e, {
status: 400,
});
}
}
6 changes: 6 additions & 0 deletions examples/client/react-router/app/routes/auth/login.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { login } from "~/auth";
import type { Route } from "./+types/login";

export function loader({ request }: Route.LoaderArgs) {
return login(request)
}
5 changes: 5 additions & 0 deletions examples/client/react-router/app/routes/auth/logout.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { logout } from "~/auth";

export function action() {
return logout()
}
31 changes: 31 additions & 0 deletions examples/client/react-router/app/routes/home.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { tryAuth } from "~/auth";
import type { Route } from "./+types/home";
import { data, Form, Link } from "react-router";

export async function loader({ request }: Route.LoaderArgs) {
const auth = await tryAuth(request)
if (!auth) {
return { user: null }
}
return data({ user: auth.subject }, { headers: auth.headers })
}

export default function Page({ loaderData }: Route.ComponentProps) {
if (!loaderData.user) {
return (
<div>
<p>You are not authorized.</p>
<Link to="/login">Login</Link>
</div>
)
}

return (
<div>
<p>Hello {loaderData.user.properties.email}</p>
<Form method="POST" action="/logout">
<button type="submit">Logout</button>
</Form>
</div>
)
}
30 changes: 30 additions & 0 deletions examples/client/react-router/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
{
"name": "@openauthjs/example-react-router",
"private": true,
"type": "module",
"scripts": {
"build": "cross-env NODE_ENV=production react-router build",
"dev": "react-router dev",
"start": "cross-env NODE_ENV=production react-router-serve ./build/server/index.js",
"typecheck": "react-router typegen && tsc"
},
"dependencies": {
"@openauthjs/openauth": "workspace:*",
"@react-router/node": "^7.0.2",
"@react-router/serve": "^7.0.2",
"isbot": "^5.1.17",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router": "^7.0.2"
},
"devDependencies": {
"@react-router/dev": "^7.0.2",
"@types/node": "^20",
"@types/react": "^19.0.1",
"@types/react-dom": "^19.0.1",
"cross-env": "^7.0.3",
"typescript": "^5.7.2",
"vite": "^5.4.11",
"vite-tsconfig-paths": "^5.1.4"
}
}
Binary file added examples/client/react-router/public/favicon.ico
Binary file not shown.
7 changes: 7 additions & 0 deletions examples/client/react-router/react-router.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import type { Config } from "@react-router/dev/config";

export default {
// Config options...
// Server-side render by default, to enable SPA mode set this to `false`
ssr: true,
} satisfies Config;
27 changes: 27 additions & 0 deletions examples/client/react-router/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
{
"include": [
"**/*",
"**/.server/**/*",
"**/.client/**/*",
".react-router/types/**/*"
],
"compilerOptions": {
"lib": ["DOM", "DOM.Iterable", "ES2022"],
"types": ["node", "vite/client"],
"target": "ES2022",
"module": "ES2022",
"moduleResolution": "bundler",
"jsx": "react-jsx",
"rootDirs": [".", "./.react-router/types"],
"baseUrl": ".",
"paths": {
"~/*": ["./app/*"]
},
"esModuleInterop": true,
"verbatimModuleSyntax": true,
"noEmit": true,
"resolveJsonModule": true,
"skipLibCheck": true,
"strict": true
}
}
7 changes: 7 additions & 0 deletions examples/client/react-router/vite.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { reactRouter } from "@react-router/dev/vite";
import { defineConfig } from "vite";
import tsconfigPaths from "vite-tsconfig-paths";

export default defineConfig({
plugins: [reactRouter(), tsconfigPaths()],
});

0 comments on commit ff5d7b5

Please sign in to comment.