npm i --save remix-auth remix-auth-github
需要用到这两个包。然后创建 auth 相关的文件,参考以下结构:
├── app
│ ├── entry.client.tsx
│ ├── entry.server.tsx
│ ├── root.tsx
│ └── routes
│ ├── _index.tsx
│ ├── auth.github.callback.ts
│ ├── auth.github.ts
│ └── private.tsx
└── tsconfig.json
在 routes
目录中创建 auth.github.ts
和 auth.github.callback.ts
两个核心文件。
由于 Cloudflare Pages 无法使用 process.env
所以采用了一种很骚的操作来实现:
// auth.server.ts
import { createCookieSessionStorage } from '@remix-run/cloudflare';
import { Authenticator } from 'remix-auth';
import type { SessionStorage } from '@remix-run/cloudflare';
import { GitHubStrategy } from 'remix-auth-github';
import { z } from 'zod';
import type { Env } from '../env';
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()
});
const SessionSchema = z.object({
user: UserSchema.optional(),
strategy: z.string().optional(),
'oauth2:state': z.string().uuid().optional(),
'auth:error': z.object({ message: z.string() }).optional()
});
export type User = z.infer<typeof UserSchema>;
export type Session = z.infer<typeof SessionSchema>;
export interface IAuthService {
readonly authenticator: Authenticator<User>;
readonly sessionStorage: TypedSessionStorage<typeof SessionSchema>;
}
export class AuthService implements IAuthService {
#sessionStorage: SessionStorage<typeof SessionSchema>;
#authenticator: Authenticator<User>;
constructor(env: Env, hostname: string) {
let sessionStorage = createCookieSessionStorage({
cookie: {
name: 'sid',
httpOnly: true,
secure: env.CF_PAGES === 'production',
sameSite: 'lax',
path: '/',
secrets: [env.COOKIE_SESSION_SECRET]
}
});
this.#sessionStorage = sessionStorage;
this.#authenticator = new Authenticator<User>(this.#sessionStorage as unknown as SessionStorage, {
throwOnError: true
});
let callbackURL = new URL(env.GITHUB_CALLBACK_URL);
callbackURL.hostname = hostname;
this.#authenticator.use(
new GitHubStrategy(
{
clientID: env.GITHUB_ID,
clientSecret: env.GITHUB_SECRET,
callbackURL: callbackURL.toString()
},
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 gh.isSponsoringMe(profile._json.node_id)
};
}
)
);
}
get authenticator() {
return this.#authenticator;
}
get sessionStorage() {
return this.#sessionStorage;
}
}
其中还用到了 zod
来定义类型,这个部分是可以忽略不用的。
如果感兴趣的话,可以访问 Zod 文档学习: https://zod.dev/
然后找到根目录下的 server.ts
进行改造,主要改造的方法为 getLoadContext
部分,在其中将 services 作为依赖进行注入:
import { logDevReady } from '@remix-run/cloudflare';
import { createPagesFunctionHandler } from '@remix-run/cloudflare-pages';
import * as build from '@remix-run/dev/server-build';
import { AuthService } from '~/server/services/auth';
import { EnvSchema } from './env';
if (process.env.NODE_ENV === 'development') {
logDevReady(build);
}
export const onRequest = createPagesFunctionHandler({
build,
getLoadContext: (ctx) => {
const env = EnvSchema.parse(ctx.env);
const { hostname } = new URL(ctx.request.url);
const auth = new AuthService(env, hostname);
const services: RemixServer.Services = {
auth
};
return { env, services };
},
mode: build.mode
});
这部分可以参考 remix-auth 的文档: https://github.com/sergiodxa/remix-auth
// auth.github.ts
import type { ActionFunction } from '@remix-run/cloudflare';
export const action: ActionFunction = async ({ request, context }) => {
return await context.services.auth.authenticator.authenticate('github', request, {
successRedirect: '/private',
failureRedirect: '/'
});
};
如果想要用 Get 请求进行登录, action
改为 loader
即可。
// auth.github.callback.ts
import type { LoaderFunction } from '@remix-run/cloudflare';
export const loader: LoaderFunction = async ({ request, context }) => {
return await context.services.auth.authenticator.authenticate('github', request, {
successRedirect: '/private',
failureRedirect: '/'
});
};
这里通过 context 将服务传递进来,避免反复使用 env
环境变量进行初始化。
然后写一个路由测试登录结果:
import type { ActionFunction, LoaderFunction } from '@remix-run/cloudflare';
import { json } from '@remix-run/cloudflare';
import { Form, useLoaderData } from '@remix-run/react';
export const action: ActionFunction = async ({ request }) => {
await auth.logout(request, { redirectTo: '/' });
};
export const loader: LoaderFunction = async ({ request, context }) => {
const profile = await context.services.auth.authenticator.isAuthenticated(request, {
failureRedirect: '/'
});
return json({ profile });
};
export default function Screen() {
const { profile } = useLoaderData<typeof loader>();
return (
<>
<Form method='post'>
<button>Log Out</button>
</Form>
<hr />
<pre>
<code>{JSON.stringify(profile, null, 2)}</code>
</pre>
</>
);
}
可以参考以下代码,新建一个路由实现:
export async function action({ request }: ActionArgs) {
await authenticator.logout(request, { redirectTo: "/login" });
};
如果需要通过类型提示的话,添加一个 .d.ts
文件,或者在 root.tsx
中添加类型声明:
import type { Env } from './env';
import type { IAuthService } from './services/auth';
declare global {
namespace RemixServer {
export interface Services {
auth: IAuthService;
}
}
}
declare module '@remix-run/cloudflare' {
interface AppLoadContext {
env: Env;
DB: D1Database;
services: RemixServer.Services;
}
}
参考这个, Env 是你自己所需要的环境变量的类型定义。
完成。完整的示例代码在: https://github.com/willin/remix-cloudflare-pages-demo/tree/c8c350ce954d14cdc68f1f9cd11cecea00600483
注意:v2 版本之后,不可以使用 remix-utils。存在兼容性问题。