remix-auth4.0でMagic Link認証を実装する
はじめに
先日 React Router7 がリリースされました。remix-auth-email-linkを使うことで簡単に Magic Link を実装できたのですが、remix-auth4 では動かすことができないため自前で実装していきます。
今回のリリースで Remix が React Router に取り込まれる形となり、この流れで認証機能を提供する remix-auth 4.0 がリリースされました。 今回のアップデートにより、ライブラリの使い方が少し変わります。これまではライブラリ | バージョン |
---|---|
react-router | 7.0.2 |
remix-auth | 4.0.0 |
remix-auth-email-link | 2.1.1 |
最初に結論
- そのままremix-auth-email-linkを v4 に対応させるのは難しいのでソースを元にライブラリを独自実装する
- Strategy クラスからメール送信処理を切り離し、クラスを 2 つに分離する
- 2 つのクラスを初期化する関数を作成して使用する
以下にサンプルを用意しました。
remix-auth 4 のアップデート内容
v4 でどのような変更があったのかをみていきます。
重要なのは 2 つ目です。
Drop RR requirement and simplify library a lot by @sergiodxa in #299
添付されている PR を見ると以下のように書かれています。
Change how the Authenticator and Strategy classes work to drop requirement of a Remix session storage at the authenticator level.
訳: Authenticator クラスと Strategy クラスの動作方法を切り離し、認証レベルでの Remix セッション・ストレージの要件を削除する。
セッションストレージの操作を remix-auth の責務から分離し、ライブラリをよりシンプルにしています。
実際に使用するときのコードを見ると分かりやすいです。
- export const authenticator = new Authenticator<User>(sessionStorage);
+ // sessionStorageはremix-authに渡さない
+ export const authenticator = new Authenticator<User>();
- return await authenticator.authenticate("user-pass", request, {
- successRedirect: "/dashboard",
- failureRedirect: "/login",
- });
+ try {
+ const user = await authenticator.authenticate("user-pass", request);
+
+ // sessionの読み書きはremix-authの外側で行う
+ const session = await sessionStorage.getSession(request.headers.get("cookie"));
+ session.set("user", user);
+
+ return redirect("/dashboard", {
+ headers: { "Set-Cookie": await sessionStorage.commitSession(session) },
+ });
+ } catch () {
+ throw redirect("/login");
+ }
- const user = await authenticator.isAuthenticated(request);
+ // sessionの読み書きはremix-authの外側で行う
+ const session = await sessionStorage.getSession(request.headers.get("cookie"));
+ const user = session.get("user");
セッションストレージの操作を remix-auth の外側で行うようになっていますね。authenticator.authenticate()
メソッドが行う処理に注目すると、以下のようになっています。
v3
- ユーザーがいればユーザーを取得し、いなければ作成
- ユーザーをセッションに書き込み
- Response を返す
v4
- ユーザーがいればユーザーを取得し、いなければ作成
- ユーザーを返す
また、シンプル化に伴ってauthenticate
の引数にも変更があります。
今までは
- strategy
- request
- options
の 3 つを受け取ることができていましたが、options はなくなり
- strategy
- request
の 2 つとなりました。
簡単に変更点が分かったので v4 で Magic Link の実装を試みます。
既存の remix-auth-email-link のソースを見てみる
実装にあたって元々使われていた remix-auth-email-link の実装を覗いてみます。
Magic Link は入力されたメールアドレスにトークンを送り、メールに届いたリンクにアクセスすると認証が始まります。
authenticate
メソッドの処理を見ると、その両方が実装されています。
メール送信:
認証:なので v3 ではメールの送信と認証の処理はどちらもauthenticate
を使って以下のように書きます。
return await authenticator.authenticate("email-link", request, {
successRedirect: "/",
});
内部で最終的に throw しているのは、セッションを書き込んで cookie を更新したレスポンスを返したいがauthenticate()
の返り値が User 型なので型エラーを起こさないためかと思われます。荒技に見えますが、remix-auth の他の Strategy を提供しているライブラリでもこの実装は見かけます。
v4 への対応で問題になる箇所
セッションストレージの処理を切り離せば動かせそうというのは分かっているので上記を v4 で使えるようにすべく、認証の処理でセッションの読み書きをしているところの対応を考えてみます。
まずはこちら
authenticate
は引数で sessionStorage を受け取ることはできないため少し困りますが Request オブジェクトを受け取ることができるため、body かどこかしらに入れて受け取ることはできそうですね。
では次
単純なセッションの破棄と書き込みなので、 v4 の使い方で見た通り外側で行うことができそうです。例えばこんな感じ
const user = await authenticator.authenticate("email-link", request);
const session = await sessionStorage.getSession(request.headers.get("cookie"));
session.unset("auth:magiclink");
session.unset("auth:email");
session.set("me", user);
return redirect("/", {
headers: { "Set-Cookie": await sessionStorage.commitSession(session) },
});
また、セッションにエラーを格納する箇所もありますが、レスポンスを外側で作成する v4 の設計であればここでは エラーを throw し、外側で catch させれば良さそうです。
ではメール送信でセッションの読み書きを行っている箇所を見てみます。
- 暗号化したマジックリンク
- メールアドレス
の 2 つをセッションに追加しています。
ここではこの 2 つを返し、外側で書き込みとレスポンスを行えばよさそう、と思うのですが、authenticate
の返り値の型はPromise<User>
です。v4 に上がってauthenticate
内で Response を返さなくてよくなった以上、正常な値を型を誤魔化すために throw させるのは気が引けます。
メソッド内でセッションを書き込んでリダイレクトさせるとしても、v4 で引数が減ったので v3 でリダイレクト先などを設定していた options を受け取ることはできなくなっています。
↓remix-authのauthenticate
の型
実装方針
ここまでで以下のことが分かりました。
- 認証はそのままでよさそう
- メール送信処理内でセッションの書き込みを行いたいが v4 の
authenticate
の設計上無理がある
これらを踏まえてメールの送信用に新しくクラスを作る方針としました。
v4 ではメール送信処理のやりたいことに対して引数と返り値が合っていませんし、1 つの関数が 2 つの目的を持っているのは汚いので。
モジュール作成
処理自体はほとんど remix-auth-email-link から持ってきています。
方針としては Strategy とメール送信の 2 つのクラスを作り、それらをまとめて初期化する関数を作ります。
完成系はこちらに用意しました。
Strategy を作成
Strategy についてはほとんど remix-auth-email-link のままで、認証に不要なメソッドなどを削除しています。
作成したクラスは配布するわけではないので、汎用性が高すぎるオプションは削ったりデフォルト値を変えるなどしています。
public async authenticate(request: Request): Promise<User> {
…
- const magicLink = session.get(this.sessionMagicLinkKey) ?? ''
+ const sessionMagicLink = await request.text();
こちらについてはauthenticate
の引数でセッションストレージは受け取れないので、外側でセッションから値を取得し、リクエストの body に含めて受け取っています。
使う側としてはこんな感じ
const session = await sessionStorage.getSession(request.headers.get("cookie"));
const magicLink = session.get("auth:magiclink");
const user = await authenticator.authenticate(
"email-link",
new Request(request.url, {
method: "POST",
headers: request.headers,
body: magicLink,
})
);
送信用のクラスを作成
public async send(
request: Request,
sessionStorage: SessionStorage,
options: SendOptions,
): Promise<Response> {
const session = await sessionStorage.getSession(request.headers.get("Cookie"));
const urlSearchParams = new URLSearchParams(await request.text());
const formData = new FormData();
for (const [name, value] of urlSearchParams) {
formData.append(name, value);
}
const email = await this.validateEmail(urlSearchParams.get(this.emailFieldKey) ?? "");
const domainUrl = this.getDomainURL(request);
const magicLink = await this.getMagicLink(email, domainUrl, formData);
await this.sendEmail({
email,
magicLink,
});
session.set(this.sessionMagicLinkKey, await this.encrypt(magicLink));
session.set(this.sessionEmailKey, email);
return redirect(options.redirect, {
headers: {
"Set-Cookie": await sessionStorage.commitSession(session),
},
});
}
こちらが v3 のauthenticate
にあたるメソッドです。別クラスとして従来と同じような処理を実行できるようにしています。
const verifyEmailAddress: VerifyEmailFunction = async (email) => {
return v.parse(
v.pipe(
v.string(),
v.nonEmpty("メールアドレスを入力してください。"),
v.email("メールアドレスの形式が正しくありません。"),
v.maxLength(40, "メールアドレスは40文字以内で入力してください。")
),
email
);
};
バリデーションは valibot を使いました。
- const user = await this.verify({
- email,
- form,
- magicLinkVerify: false,
- }).catch(() => null)
-
- await this.sendEmail({
- emailAddress: email,
- magicLink,
- user,
- domainUrl,
- form,
- })
+ await this.sendEmail({
+ email,
+ magicLink,
+ });
こちらは完全な好みなのですがメール送信時点でユーザーを作成するのは嫌なのでverify
の実行はしないようにしています。悪意を持ってユーザーデータを作りまくることもできるので…
2 つのクラスを初期化する関数の作成
他の Strategy モジュールと少し使い方が違いますが、これが一番綺麗かと思います。
magic link 認証を実装する
作成したクラスを使って実際に認証フローを作成します。
こちらのリポジトリで動くものを用意していますので、要点だけ抜粋します。インスタンスの初期化から。
const { sendToken, emailLinkStrategy } = createMagicLinkInstances({
secret: env.CRYPTO_SECRET,
sendEmail: async ({ email, magicLink }) => {
transporter.sendMail({
from: `Auth <${env.MAIL_ADDRESS}>`,
to: email,
subject: "ログインメール",
html: `
<div style="max-width:600px;margin:0 auto;text-align:center;">
<p>
このメールはログインするためのメールです。
<br>
ログインを完了するには、以下のボタンをクリックしてください
</p>
<a href="${magicLink}" style="margin:24px auto;background:rgb(0,111,238);color:rgb(255,255,255);display:inline-block;border-radius:12px;width:240px;padding:10px 16px;align-items:center;justify-content:center;text-decoration:none;font-weight:bold;font-size:0.875rem;">
ログイン
</a>
<p>または、下記のURLをコピーしてブラウザの新しいタブに貼り付けてください</p>
<p><a href="${magicLink}" style="text-align:start;">${magicLink}</a></p>
</div>`,
});
},
verify: async ({ email }) => {
const me = await prisma.user.findUnique({
where: {
email,
},
});
if (me) {
if (!me.isActive) {
throw new Error("アカウントが無効です。");
}
return me;
} else {
return prisma.user.create({
data: {
email,
name: email.split("@")[0],
},
});
}
},
});
export { sendToken, emailLinkStrategy };
作成した関数を使って 2 つのクラスのインスタンスを作成してエクスポートしています。
次にメール送信エンドポイント
export async function action({ request }: LoaderFunctionArgs) {
try {
return sendToken.send(request, sessionStorage, {
redirect: "/login",
});
} catch (error) {
console.error("メールの送信に失敗しました", error);
if (error instanceof v.ValiError) {
console.error("メールアドレスの形式が正しくありません", error);
}
return redirect("/login");
}
}
認証エンドポイント(メールに送られるリンク先)
export async function loader({ request }: LoaderFunctionArgs) {
const session = await sessionStorage.getSession(
request.headers.get("cookie")
);
try {
const magicLink = session.get("auth:magiclink");
const user = await authenticator.authenticate(
"email-link",
new Request(request.url, {
method: "POST",
headers: request.headers,
body: magicLink,
})
);
session.unset("auth:magiclink");
session.unset("auth:email");
session.set("me", user);
return redirect("/", {
headers: {
"Set-Cookie": await sessionStorage.commitSession(session),
},
});
} catch (error) {
console.error("ログインに失敗しました。", error);
session.unset("auth:magiclink");
session.unset("auth:email");
return redirect("/login", {
headers: {
"Set-Cookie": await sessionStorage.commitSession(session),
},
});
}
}
前述した通り、authenticate
にセッションストレージを渡すことはできません。セッションからマジックリンクを取得し、body に含めた request を新たに作成して渡しています。
ログインページ
export async function loader({ request }: LoaderFunctionArgs) {
const session = await sessionStorage.getSession(request.headers.get("cookie"));
const me: User = session.get("me");
return { me, isMagicLinkSent: session.get("auth:magiclink") };
}
export default function Login({ loaderData }: { loaderData: Awaited<ReturnType<typeof loader>> }) {
return (
<Card className="w-full px-4">
<CardHeader className="text-xl font-bold">ログインページ</CardHeader>
<Divider />
<CardBody className="pt-10">
{loaderData.me ? (
<div className="w-full flex flex-col items-center gap-4">
<p>ログイン済みです。</p>
<Button color="primary" radius="full" to="/" as={RRLink} className="w-fit">
トップへ戻る
</Button>
</div>
) : (
<>
{loaderData.isMagicLinkSent ? (
<div className="mt-8">
<p className="text-xl font-bold text-center mb-6">ログインメールを送信しました。</p>
<p className="mb-2">メール内のリンクをクリックしてログインしてください。</p>
<p>リンクは30分間有効です。</p>
</div>
) : (
<Form
action="/api/auth/email-link"
method="post"
className="w-full flex flex-col items-center gap-4"
>
<Input type="text" name="email" variant="bordered" label="メールアドレス" />
<Button type="submit" color="primary" radius="full" className="w-fit">
ログイン
</Button>
</Form>
)}
<div className="mt-16">
<Link to="/" as={RRLink}>
トップページへ
</Link>
</div>
</>
)}
</CardBody>
</Card>
);
}
終わりに
他の strategy を提供しているモジュールだと早々に v4 対応されています。対応がまだでも対応の PR は上がっているのですが、remix-auth-email-link については対応される兆しがまだ見えなかったので作成しました。実際に対応してみてv4ではstrategyの提供だけでは難しく、対応が遅れている理由を感じました。
認証以外でも今回の remix から react router の移行では色々つまづきポイントがあるので根強くやって行きます💪
Discussion