原文链接:seanpaulcampbell.com/blog/openau…
在本指南中,我们将逐步演示如何在 React Router 中实现 OpenAuth。之所以撰写此指南,是因为我曾花了不少时间才理清实现思路,希望这份指南能对您有所帮助!请注意,这仅是我个人的实现方式,实际存在多种实现方案。
该方案的高级概述如下:
我们采用OpenAuth作为用户认证方案。当通过邮件验证码或GitHub等社交账户验证用户身份后,即可获取身份凭证(邮箱或社交账户),并基于此信息在数据库中执行授权操作。这样既能验证是否存在对应身份的用户,若不存在则引导用户完成注册流程,并可能进行计费操作。
我们将涵盖的内容
- 为我们的Web和身份验证服务器设置SST
- 使用提供商选项配置身份验证签发者
- 在React Router中实现身份验证流程
- 管理经过身份验证的会话
- 处理回调和重定向
- 创建受保护的路由
- 如何通过创建用户来处理用户引导流程
前提条件
开始之前,请确保您已具备以下条件:
- 安装了 Node.js 及 npm/yarn/bun
- 对 React 和 React Router 有基础了解
- 掌握 SST 基础知识
- 拥有 AWS 账户(用于 SST 部署)
项目设置
首先创建一个使用 React Router 的新 SST 项目:
# Create a new React Router project
mkdir openauth-react-router
cd openauth-react-router
bunx create-react-router@latest
# Setup SST
bunx sst init
# Install openauth
bun add @openauth/openauth
设置 SST 身份验证
我们的第一步是使用 SST 设置身份验证服务器。打开您的 sst.config.ts 文件,并添加以下配置:
/// <reference path="./.sst/platform/config.d.ts" />
export default $config({
app(input) {
return {
name: "openauth-react-router",
removal: input?.stage === "production" ? "retain" : "remove",
protect: ["production"].includes(input?.stage),
home: "aws",
providers: {
aws: {
region: "us-east-1",
// Replace with your AWS profile
profile: "your-aws-profile",
},
},
};
},
async run() {
const auth = new sst.aws.Auth("AuthServer", {
issuer: {
handler: "./packages/functions/src/auth/issuer.handler",
},
});
const web = new sst.aws.React("Web", {
environment: {
VITE_AUTH_URL: auth.url,
VITE_SITE_URL: "http://localhost:5173",
},
});
},
});
此配置:
- 创建名为“AuthServer”的SST认证构造体
- 指向我们的签发者处理程序函数
- 为认证URL和站点URL设置环境变量的React应用程序
创建认证处理程序
接下来,我们需要创建认证处理程序。创建目录结构:
packages/
functions/
src/
auth/
issuer.ts
现在,让我们实现基本的发行者处理程序:
import { issuer } from "@openauthjs/openauth";
import { CodeProvider } from "@openauthjs/openauth/provider/code";
import { CodeUI } from "@openauthjs/openauth/ui/code";
import { handle } from "hono/aws-lambda";
import { authSubjects } from "./subjects";
const app = issuer({
subjects: authSubjects,
allow: async () => true,
providers: {
email: CodeProvider(
CodeUI({
sendCode: async (email, code) => {
console.log("send code: ", email, code);
},
})
),
},
success: async (ctx, value) => {
if (value.provider === "email") {
const email = value.claims.email;
if (!email) {
throw new Error("No email found");
}
return ctx.subject(
"account",
{ type: "email", email },
{ subject: email }
);
}
throw new Error("Invalid provider");
},
});
export const handler = handle(app);
授权主题
import { createSubjects } from "@openauthjs/openauth/subject";
import { z } from "zod";
const EmailAccount = z.object({
type: z.literal("email"),
email: z.string(),
});
export type EmailAccount = z.infer<typeof EmailAccount>;
export type Account = EmailAccount;
const AccountSchema = z.discriminatedUnion("type", [EmailAccount]);
export const authSubjects = createSubjects({
account: AccountSchema,
});
在 React Router 中设置身份验证模块
现在,让我们在 React 应用程序中设置身份验证模块。我们将创建几个关键文件:
1. 身份验证会话存储
接下来,我们将创建一个服务器端身份验证存储来管理会话。部分代码灵感来源于The Epic Stack。感谢Kent C. Dodds的杰出工作!该会话存储将用于保存从OpenAuth服务器获取的JWTtoken。
import type { Tokens } from "@openauthjs/openauth/client";
import { type SessionStorage, createCookieSessionStorage } from "react-router";
interface SessionData {
tokens: Tokens;
expires?: Date;
}
type SessionFlashData = {
error: string;
};
const AUTH_SESSION_KEY = "en_session";
export const authSessionStorage = createCookieSessionStorage<
SessionData,
SessionFlashData
>({
cookie: {
name: AUTH_SESSION_KEY,
sameSite: "lax",
path: "/",
httpOnly: true,
secrets: ["secret"],
secure: process.env.NODE_ENV === "production",
},
});
export function getSessionDefaultExpiration() {
return new Date(Date.now() + 1000 * 60 * 60 * 24 * 30); // 30 days
}
// we have to do this because every time you commit the session you overwrite it
// so we store the expiration time in the cookie and reset it every time we commit
const originalCommitSession = authSessionStorage.commitSession;
Object.defineProperty(authSessionStorage, "commitSession", {
value: async function commitSession(
...args: Parameters<typeof originalCommitSession>
) {
const [session, options] = args;
if (options?.expires) {
session.set("expires", options.expires);
}
if (options?.maxAge) {
session.set("expires", new Date(Date.now() + options.maxAge * 1000));
}
const expires = session.has("expires")
? new Date(session.get("expires") as Date)
: undefined;
const setCookieHeader = await originalCommitSession(session, {
...options,
expires,
});
return setCookieHeader;
},
});
export class AuthSessionController {
#sessionStorage: SessionStorage<SessionData, SessionFlashData>;
constructor(sessionStorage: SessionStorage<SessionData, SessionFlashData>) {
this.#sessionStorage = sessionStorage;
}
get sessionStorage() {
return this.#sessionStorage;
}
async getSession(request: Request) {
return this.#sessionStorage.getSession(request.headers.get("Cookie"));
}
async getSessionData(request: Request): Promise<Partial<SessionData>> {
const session = await this.getSession(request);
const tokens = session.get("tokens");
const expires = session.get("expires");
return {
tokens,
expires,
};
}
async setSessionData(data: SessionData) {
const session = await this.#sessionStorage.getSession();
session.set("tokens", data.tokens);
if (data.expires) {
session.set("expires", data.expires);
}
const headers = new Headers();
headers.append(
"Set-Cookie",
await this.#sessionStorage.commitSession(session)
);
return headers;
}
async destroySession(request: Request) {
const session = await this.getSession(request);
const headers = new Headers();
headers.append(
"Set-Cookie",
await this.#sessionStorage.destroySession(session)
);
return headers;
}
}
2. 身份验证器
接下来是一个围绕 OpenAuth client构建的小型辅助类,用于协助 React Router 的身份验证流程。该实现深受 Remix Auth OpenAuth 的启发。感谢 Sergio Xalambrí 的出色工作!
import type { SetCookieInit } from "@mjackson/headers";
import { type Tokens, createClient } from "@openauthjs/openauth/client";
import * as OpenAuthError from "@openauthjs/openauth/error";
import type { SubjectSchema } from "@openauthjs/openauth/subject";
import { redirect } from "react-router";
import { combineHeaders } from "../../utils/misc.server";
import { StateStore } from "./store.server";
type FetchLike = NonNullable<Parameters<typeof createClient>["0"]["fetch"]>;
export interface AuthenticatorOption<T extends SubjectSchema = SubjectSchema> {
/**
* The redirect URI of the application you registered in the OpenAuth
* server.
*
* This is where the user will be redirected after they authenticate.
*
* @example
* "https://example.com/auth/callback"
*/
redirectUri: string;
/**
* The client ID of the application you registered in the OpenAuth server.
* @example
* "my-client-id"
*/
clientId: string;
/**
* The issuer of the OpenAuth server you want to use.
* This is where your OpenAuth server is hosted.
* @example
* "https://openauth.example.com"
*/
issuer: string;
/**
* The name of the cookie used to keep state and code verifier around.
*
* The OAuth2 flow requires generating a random state and code verifier, and
* then checking that the state matches when the user is redirected back to
* the application. This is done to prevent CSRF attacks.
*
* The state and code verifier are stored in a cookie, and this option
* allows you to customize the name of that cookie if needed.
* @default "oauth2"
*/
cookie?: string | (Omit<SetCookieInit, "value"> & { name: string });
/**
* A custom fetch implementation to use when making requests to the OAuth2
* server. This can be useful when you need to replace the default fetch
* to use a proxy, for example.
*/
fetch?: FetchLike;
subjects: T;
}
export class Authenticator<T extends SubjectSchema> {
name = "openauth";
#client: ReturnType<typeof createClient>;
#options: AuthenticatorOption<T>;
#subjects: T;
constructor(options: AuthenticatorOption<T>) {
this.#options = options;
this.#client = createClient({
clientID: options.clientId,
issuer: options.issuer,
fetch: options.fetch,
});
this.#subjects = options.subjects;
}
get #cookieName() {
if (typeof this.#options.cookie === "string") {
return this.#options.cookie || "oauth2";
}
return this.#options.cookie?.name ?? "oauth2";
}
get #cookieOptions() {
if (typeof this.#options.cookie !== "object") return {};
return this.#options.cookie ?? {};
}
/**
* Throws a redirect to the authorization endpoint.
*/
async authorize(
_request: Request,
options?: {
provider?: string;
redirectUri?: string;
type?: "login" | "email-verify";
headers?: Headers;
}
): Promise<void> {
const { state, verifier, url, redirectUri } =
await this.#createAuthorizationURL(options);
// Create a cookie prefix based on type
const cookiePrefix = options?.type
? `${this.#cookieName}-${options.type}`
: this.#cookieName;
const store = new StateStore();
store.set(state, verifier, redirectUri);
const setCookie = store.toSetCookie(cookiePrefix, this.#cookieOptions);
const headers = new Headers();
headers.append("Set-Cookie", setCookie.toString());
throw redirect(url.toString(), {
headers: combineHeaders(headers, options?.headers),
});
}
async exchange(
request: Request,
options?: {
type?: "login" | "email-verify";
}
) {
const url = new URL(request.url);
const code = url.searchParams.get("code");
const stateUrl = url.searchParams.get("state");
// Create a cookie prefix based on type
const cookiePrefix = options?.type
? `${this.#cookieName}-${options.type}`
: this.#cookieName;
const store = StateStore.fromRequest(request, cookiePrefix);
if (!code) throw new ReferenceError("Missing authorization code.");
if (!stateUrl) throw new ReferenceError("Missing state in URL.");
if (!store.state) throw new ReferenceError("Missing state in cookie.");
if (store.state !== stateUrl) {
throw new RangeError(
`State mismatch. Cookie: ${store.state}, URL: ${stateUrl}`
);
}
if (!store.codeVerifier) {
throw new ReferenceError("Missing code verifier in cookie.");
}
// Get the redirect URI that was saved during authorization
const redirectUri = store.redirectUri ?? this.#options.redirectUri;
const result = await this.#client.exchange(
code,
redirectUri,
store.codeVerifier
);
if (result.err) throw result.err;
const cleanCookie = StateStore.cleanCookie(cookiePrefix);
const headers = new Headers();
headers.append("Set-Cookie", cleanCookie.toString());
return {
tokens: result.tokens,
headers,
};
}
/**
* Refreshes the access token using the provided refresh token.
*
* @param refresh - The refresh token to use for obtaining a new access token.
* @param access - An optional access token to validate if it needs to be refreshed.
* @returns The new tokens obtained after refreshing.
*/
async refreshToken(
refresh: string,
access?: string
): Promise<Tokens | undefined> {
const result = await this.#client.refresh(refresh, { access });
if (result.err) throw result.err;
if (!result.tokens && access) return { access, refresh, expiresIn: 0 };
if (!access && !result.tokens) throw new Error("No tokens returned");
return result.tokens;
}
async verifyToken(
token: string,
options?: { refresh: string; audience?: string }
) {
const result = await this.#client.verify(this.#subjects, token, {
...options,
issuer: this.#options.issuer,
fetch: this.#options.fetch as typeof fetch,
});
const clone = structuredClone(result);
return clone;
}
async #createAuthorizationURL(options?: {
provider?: string;
redirectUri?: string;
}) {
const redirectUri = options?.redirectUri ?? this.#options.redirectUri;
const provider = options?.provider;
const result = await this.#client.authorize(redirectUri, "code", {
pkce: true,
provider: provider,
});
const url = new URL(result.url);
url.searchParams.set("state", result.challenge.state);
return { ...result.challenge, url, redirectUri };
}
}
export class OAuth2RequestError extends Error {
code: string;
description: string | null;
uri: string | null;
state: string | null;
constructor(
code: string,
description: string | null,
uri: string | null,
state: string | null
) {
super(`OAuth request error: ${code}`);
this.code = code;
this.description = description;
this.uri = uri;
this.state = state;
}
}
export { OpenAuthError };
状态存储
状态存储用于保存OAuth流程的状态和代码验证器。它将负责存储代码验证器和状态的Cookie。
import { Cookie, SetCookie, type SetCookieInit } from "@mjackson/headers";
/**
* This class stores all necessary information for the OAuth flow.
* It follows the same pattern as the OpenAuth SPA implementation.
*/
export class StateStore {
state: string | undefined;
codeVerifier: string | undefined;
redirectUri: string | undefined;
constructor(state?: string, codeVerifier?: string, redirectUri?: string) {
this.state = state;
this.codeVerifier = codeVerifier;
this.redirectUri = redirectUri;
}
/**
* Set the state, code verifier, and redirect URI
*/
set(state: string, verifier?: string, redirectUri?: string) {
this.state = state;
this.codeVerifier = verifier;
this.redirectUri = redirectUri;
}
/**
* Check if the store has a specific state
*/
has(checkState?: string) {
if (!this.state) return false;
return checkState ? this.state === checkState : true;
}
/**
* Get the code verifier for the current state
*/
get(checkState: string) {
if (checkState === this.state) {
return this.codeVerifier;
}
return undefined;
}
/**
* Get the redirect URI that was used for this auth flow
*/
getRedirectUri() {
return this.redirectUri;
}
toString() {
if (!this.state) return "";
if (!this.codeVerifier) return "";
const params = new URLSearchParams();
params.set("state", this.state);
params.set("codeVerifier", this.codeVerifier);
if (this.redirectUri) {
params.set("redirectUri", this.redirectUri);
}
return params.toString();
}
/**
* Convert the store to cookie for storage
*/
toSetCookie(
cookieName = "oauth2",
options: Omit<SetCookieInit, "value"> = {}
) {
return new SetCookie({
value: this.toString(),
httpOnly: true, // Prevents JavaScript from accessing the cookie
maxAge: 60 * 5, // 5 minutes
path: "/",
sameSite: "Lax",
...options,
name: cookieName,
});
}
/**
* Create a new instance from a Request object
*/
static fromRequest(request: Request, cookieName = "oauth2") {
const cookie = new Cookie(request.headers.get("cookie") ?? "");
const cookieValue = cookie.get(cookieName);
if (!cookieValue) {
return new StateStore();
}
const params = new URLSearchParams(cookieValue);
const state = params.get("state") || undefined;
const verifier = params.get("codeVerifier") || undefined;
const redirectUri = params.get("redirectUri") || undefined;
return new StateStore(state, verifier, redirectUri);
}
static cleanCookie(cookieName = "oauth2") {
return new SetCookie({
value: "",
maxAge: 0,
httpOnly: true,
expires: new Date(0),
path: "/",
sameSite: "Lax",
name: cookieName,
});
}
}
3. 身份验证与授权功能
此处将处理身份验证与授权流程。我们将通过辅助函数中的身份验证器authenticator来管理路由的身份验证与授权流程。
我们提供以下辅助函数:
getSessionDataWithUser- 获取会话数据及已认证用户的身份信息requireSessionData- 强制要求会话数据,若未认证则重定向至登录页面requireSessionWithUser- 强制要求会话数据及已认证用户的身份信息requireAnonymous- 强制要求会话数据,若未认证则重定向至首页
此外还提供 sessionController 组件,用于处理会话数据及用户身份验证相关操作。
import type { Tokens } from "@openauthjs/openauth/client";
import { redirect } from "react-router";
import {
type EmailAccount,
type OAuthAccount,
authSubjects,
} from "../../../packages/functions/src/auth/subjects";
import { combineHeaders } from "../../utils/misc.server";
import type { User } from "../users/service.server";
import * as UserService from "../users/service.server";
import {
AuthSessionController,
authSessionStorage,
getSessionDefaultExpiration,
} from "./auth-session-storage.server";
import { Authenticator } from "./authenticator.server";
export const authenticator = new Authenticator<typeof authSubjects>({
clientId: "web",
redirectUri: `${import.meta.env.VITE_SITE_URL}/auth/callback`,
issuer: import.meta.env.VITE_AUTH_URL,
subjects: authSubjects,
});
export async function handleAuthCallback(request: Request) {
try {
const { tokens, headers: exchangeHeaders } = await authenticator.exchange(
request
);
const verified = await authenticator.verifyToken(tokens.access, {
refresh: tokens.refresh,
});
if (verified.err) {
throw redirect("/", {
headers: combineHeaders(
await sessionController.destroySession(request),
exchangeHeaders
),
});
}
if (verified.subject.type !== "account") {
throw new Error("Invalid subject type");
}
if (verified.subject.properties.type === "email") {
return handleEmailFlow({
tokens,
verified: verified.subject.properties,
exchangeHeaders,
});
}
throw new Error("Invalid subject type");
} catch (error) {
if (error instanceof Response) {
throw error;
}
console.error("Error handling callback:", error);
throw redirect("/logout");
}
}
async function handleEmailFlow({
tokens,
verified,
exchangeHeaders,
}: {
tokens: Tokens;
verified: EmailAccount;
exchangeHeaders: Headers;
}) {
const user = await UserService.userByEmail(verified.email);
if (!user) {
const headers = await sessionController.setSessionData({
tokens: tokens,
expires: getSessionDefaultExpiration(),
});
throw redirect("/onboarding", {
headers: combineHeaders(headers, exchangeHeaders),
});
}
const headers = await sessionController.setSessionData({
tokens: tokens,
expires: getSessionDefaultExpiration(),
});
return {
user,
headers: combineHeaders(headers, exchangeHeaders),
};
}
const sessionController = new AuthSessionController(authSessionStorage);
interface SessionData {
tokens: Tokens;
properties: EmailAccount | OAuthAccount;
headers: Headers;
}
export async function getSessionData(request: Request) {
const sessionData = await sessionController.getSessionData(request);
if (!sessionData.tokens) {
return undefined;
}
let headers = new Headers();
const verified = await authenticator.verifyToken(sessionData.tokens.access, {
refresh: sessionData.tokens.refresh,
});
// if the token is invalid, destroy the session
// and redirect to the home page where we can login
if (verified.err || verified.subject.type !== "account") {
throw redirect("/", {
headers: await sessionController.destroySession(request),
});
}
// if there are new tokens from the refreshing, update the session
if (verified.tokens) {
sessionData.tokens = verified.tokens;
headers = await sessionController.setSessionData({
tokens: verified.tokens,
expires: getSessionDefaultExpiration(),
});
}
return {
tokens: sessionData.tokens,
properties: verified.subject.properties,
headers,
};
}
export async function requireSessionData(
request: Request,
{ redirectTo }: { redirectTo?: string | null } = {}
) {
const sessionData = await getSessionData(request);
if (!sessionData) {
throw redirect(getLoginRedirectUrl(request, redirectTo));
}
return sessionData;
}
/**
* Get the session data from the request
* @param request - The request object
* @throws redirect to the onboarding page if we have a valid session but no user
* @returns The session data
*/
export async function getSessionWithUser(request: Request): Promise<
| {
sessionData: SessionData;
user: User;
}
| undefined
> {
const sessionData = await getSessionData(request);
if (!sessionData) {
return undefined;
}
const user = await UserService.userByEmail(sessionData.properties.email);
if (!user) {
throw redirect("/onboarding");
}
return {
sessionData,
user,
};
}
export async function requireSessionWithUser(
request: Request,
{ redirectTo }: { redirectTo?: string | null } = {}
) {
const sessionData = await requireSessionData(request, { redirectTo });
const user = await UserService.userByEmail(sessionData.properties.email);
if (!user) {
throw redirect("/onboarding");
}
return {
sessionData,
user,
};
}
export async function requireAnonymous(request: Request) {
const sessionData = await getSessionData(request);
if (sessionData) {
throw redirect("/");
}
}
function getLoginRedirectUrl(
request: Request,
redirectTo?: string | null
): string {
const requestUrl = new URL(request.url);
const to =
redirectTo === null
? null
: redirectTo ?? `${requestUrl.pathname}${requestUrl.search}`;
const params = to ? new URLSearchParams({ redirectTo: to }) : null;
const loginRedirect = ["/login", params?.toString()]
.filter(Boolean)
.join("?");
return loginRedirect;
}
export async function handleLogout(
request: Request,
{
redirectTo = "/",
responseInit,
}: {
redirectTo?: string;
responseInit?: ResponseInit;
} = {}
) {
const headers = await sessionController.destroySession(request);
throw redirect(redirectTo, {
...responseInit,
headers: combineHeaders(headers, responseInit?.headers),
});
}
export async function handleSignup({
email,
name,
tokens,
}: {
email: string;
name: string;
tokens: Tokens;
}) {
const user = await UserService.signup({ email, name });
const headers = await sessionController.setSessionData({
tokens,
expires: getSessionDefaultExpiration(),
});
return { user, headers };
}
在 React Router 中实现身份验证路由
既然我们已经拥有了身份验证模块,接下来就来实现必要的路由:
1. 登录路由
登录路由的职责是将用户重定向至 OpenAuth 服务器以启动 OAuth 流程。该流程将在 Cookie 中设置状态和代码验证器,随后将用户重定向至 OpenAuth 服务器。
import {
authenticator,
requireAnonymous,
} from "../../modules/auth/auth.server";
import type { Route } from "./+types/login";
export async function loader({ request }: Route.LoaderArgs) {
await requireAnonymous(request);
throw await authenticator.authorize(request);
}
2. 回调路由
在回调路由中,我们将处理来自OpenAuth服务器的响应。我们将验证令牌,然后设置会话数据。
import type { LoaderFunctionArgs } from "react-router";
import { redirect } from "react-router";
import { handleAuthCallback } from "../../modules/auth/auth.server";
export async function loader({ request }: LoaderFunctionArgs) {
const { headers } = await handleAuthCallback(request);
return redirect("/protected", {
// this could be any route you want
headers: headers,
});
}
3. 注销路径
注销路径将销毁会话并重定向用户至主页。
import { handleLogout } from "../../modules/auth/auth.server";
import type { Route } from "./+types/logout";
export async function loader({ request }: Route.LoaderArgs) {
return handleLogout(request);
}
export async function action({ request }: Route.ActionArgs) {
return handleLogout(request);
}
创建注册引导组件
在 handleAuthCallback 函数中,我们会检查用户是否已在我们的"数据库"中存在。若不存在,则将用户重定向至注册引导页面。无论用户是否为新用户,我们都会使用令牌设置会话数据,并根据情况进行相应重定向。
在引导页面上,我们将通过 requireSessionData 函数确保存在有效会话。该函数将返回用户的邮箱地址,我们可据此填写表单并验证用户是否已存在于我们的“数据库”中。
对于新用户,我们将创建引导组件:
import {
type SubmissionResult,
getFormProps,
getInputProps,
useForm,
} from "@conform-to/react";
import { getZodConstraint, parseWithZod } from "@conform-to/zod";
import {
Form,
data,
redirect,
useActionData,
useLoaderData,
useSearchParams,
} from "react-router";
import { HoneypotInputs } from "remix-utils/honeypot/react";
import { safeRedirect } from "remix-utils/safe-redirect";
import { z } from "zod";
import { ErrorList, Field } from "../../components/ui/forms";
import { StatusButton } from "../../components/ui/status-button";
import { authSessionStorage } from "../../modules/auth/auth-session-storage.server";
import {
handleSignup,
requireSessionData,
} from "../../modules/auth/auth.server";
import * as UserService from "../../modules/users/service.server";
import { useIsPending } from "../../utils/misc";
import type { Route } from "./+types/onboarding";
export const OnboardingSchema = z.object({
email: z.string().email(),
name: z.string(),
redirectTo: z.string().optional(),
});
export async function loader({ request }: Route.LoaderArgs) {
const { email } = await requireOnboardingData(request);
const authSession = await authSessionStorage.getSession(
request.headers.get("cookie")
);
const formError = authSession.get("error");
const hasError = typeof formError === "string";
return data({
email: email,
status: "idle",
submission: {
status: hasError ? "error" : undefined,
initialValue: {
email: email,
},
error: { "": hasError ? [formError] : [] },
} as SubmissionResult,
});
}
export async function action({ request }: Route.ActionArgs) {
const onboardingData = await requireOnboardingData(request);
const formData = await request.formData();
const submission = await parseWithZod(formData, {
async: true,
schema: (intent) =>
OnboardingSchema.superRefine(async (data, ctx) => {
try {
const existingUser = await UserService.userByEmail(data.email);
if (existingUser) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "A user with this email already exists.",
path: ["email"],
});
return;
}
if (data.email !== onboardingData.email) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Email does not match the email provided.",
path: ["email"],
});
return;
}
} catch (error) {
console.error("Error verifying token:", error);
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "An error occurred while verifying the token.",
path: ["email"],
});
}
}).transform(async (data) => {
if (intent !== null) return { ...data, headers: null };
const { headers } = await handleSignup({
email: data.email,
name: data.name,
tokens: onboardingData.tokens,
});
return { ...data, headers };
}),
});
if (submission.status !== "success" || !submission.value.headers) {
return data(
{ result: submission.reply() },
{ status: submission.status === "error" ? 400 : 200 }
);
}
const { redirectTo, headers } = submission.value;
return redirect(safeRedirect(redirectTo, "/protected"), { headers });
}
export default function OnboardingProviderRoute() {
const loaderData = useLoaderData<typeof loader>();
const actionData = useActionData<typeof action>();
const [searchParams] = useSearchParams();
const redirectTo = searchParams.get("redirectTo");
const isPending = useIsPending();
const [form, fields] = useForm({
id: "onboarding-form",
constraint: getZodConstraint(OnboardingSchema),
lastResult: actionData?.result ?? loaderData.submission,
onValidate({ formData }) {
return parseWithZod(formData, { schema: OnboardingSchema });
},
shouldRevalidate: "onBlur",
});
return (
<div className="min-h-screen flex items-center justify-center bg-background">
<div className=" flex p-8 flex-col items-center justify-center gap-6 rounded-lg shadow-md w-full max-w-xl bg-card">
<header className="mb-2 flex flex-col gap-2">
<h1 className="font-display text-center text-5xl font-semibold text-foreground">
Create your account
</h1>
<p className="text-center text-base font-normal text-muted-foreground">
Join thousands of users today
</p>
</header>
<div className="space-y-8 w-full">
<Form
method="POST"
autoComplete="off"
className="flex w-full flex-col items-start gap-1"
{...getFormProps(form)}
>
<HoneypotInputs />
{redirectTo ? (
<input
{...getInputProps(fields.redirectTo, { type: "hidden" })}
value={redirectTo}
/>
) : null}
<div className="grid w-full grid-cols-1 gap-6 md:grid-cols-6">
<div className="col-span-full md:col-span-full md:col-start-1">
<Field
labelProps={{
children: "Name",
}}
inputProps={{
...getInputProps(fields.name, {
type: "text",
}),
autoFocus: true,
}}
errors={fields.name.errors}
/>
</div>
<div className="col-span-full md:col-span-full">
<Field
labelProps={{
children: "Email",
}}
inputProps={{
...getInputProps(fields.email, { type: "email" }),
className: "lowercase",
autoComplete: "email",
readOnly: true,
}}
errors={fields.email.errors}
/>
</div>
</div>
<div>
<ErrorList errors={form.errors} id={form.errorId} />
</div>
<div className="mt-8 w-full">
<StatusButton
type="submit"
status={isPending ? "pending" : form.status ?? "idle"}
className="w-full"
>
Create Account
</StatusButton>
</div>
</Form>
<Form method="POST" action="/logout">
<p className="text-body-sm text-muted-foreground">
Want to use a different email?{" "}
<button
type="submit"
className="text-body-sm text-muted-foreground hover:underline"
>
Sign out
</button>
</p>
</Form>
</div>
</div>
</div>
);
}
async function requireOnboardingData(request: Request) {
const sessionData = await requireSessionData(request);
if (!sessionData.tokens) {
throw new Error("No tokens found");
}
const result = z
.object({
email: z.string().email(),
tokens: z.object({
access: z.string(),
refresh: z.string(),
expiresIn: z.number(),
}),
})
.safeParse({
email: sessionData.properties.email,
tokens: sessionData.tokens,
});
if (!result.success) {
console.log("requireOnboardingData: result", result);
throw redirect("/");
}
return result.data;
}
保护路由
既然认证系统已就绪,现在我们来创建一个用于保护路由的实用程序。若用户未通过认证,系统将重定向至登录页面以启动认证流程。
import { requireSessionWithUser } from "../modules/auth/auth.server";
import type { Route } from "./+types/protected";
export async function loader({ request }: Route.LoaderArgs) {
const { user } = await requireSessionWithUser(request);
return { user };
}
export default function Protected({ loaderData }: Route.ComponentProps) {
return (
<div>
<h1>Protected</h1>
<pre>{JSON.stringify(loaderData, null, 2)}</pre>
</div>
);
}
试一试
现在我们已完成所有配置,接下来部署我们的应用程序:
bunx sst dev
部署完成后,您可以访问应用程序网址并尝试认证流程:
- 点击“登录”开始认证流程
- 输入您的邮箱地址
- 完成验证码验证步骤
- 返回应用程序页面
- 完成注册流程(新用户)
- 访问受保护的路由!
总结
本指南通过结合OpenAuth、React Router和SST构建了完整的认证系统。该方案具备以下优势:
- 基于JWT令牌的安全认证
- 支持多认证提供商
- 极简代码实现受保护路由
- 无服务器认证基础设施
您可通过添加更多提供商、优化用户体验或集成其他服务来扩展此方案。核心认证流程保持不变,使系统兼具稳健性与灵活性。
下篇指南将新增GitHub提供商,演示其流程的回调处理方案,并为OpenAuth OAuth流程创建自定义UI页面。
完整代码示例详见GitHub仓库。