🔐

remix-auth4.0でMagic Link認証を実装する

2024/12/12に公開

はじめに

先日 React Router7 がリリースされました。
https://remix.run/blog/react-router-v7
今回のリリースで Remix が React Router に取り込まれる形となり、この流れで認証機能を提供する remix-auth 4.0 がリリースされました。
https://github.com/sergiodxa/remix-auth/releases/tag/v4.0.0
今回のアップデートにより、ライブラリの使い方が少し変わります。これまではremix-auth-email-linkを使うことで簡単に Magic Link を実装できたのですが、remix-auth4 では動かすことができないため自前で実装していきます。

ライブラリ バージョン
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 つのクラスを初期化する関数を作成して使用する

以下にサンプルを用意しました。
https://github.com/sendo-kakeru/react-router7-remix-auth4-magic-link

remix-auth 4 のアップデート内容

v4 でどのような変更があったのかをみていきます。

https://github.com/sergiodxa/remix-auth/releases/tag/v4.0.0
重要なのは 2 つ目です。

Drop RR requirement and simplify library a lot by @sergiodxa in #299

添付されている PR を見ると以下のように書かれています。
https://github.com/sergiodxa/remix-auth/pull/299

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

  1. ユーザーがいればユーザーを取得し、いなければ作成
  2. ユーザーをセッションに書き込み
  3. Response を返す

v4

  1. ユーザーがいればユーザーを取得し、いなければ作成
  2. ユーザーを返す

また、シンプル化に伴ってauthenticateの引数にも変更があります。
https://github.com/sergiodxa/remix-auth/blob/d4eb06d6362a8e67bf137c96ce7a4a503d8e2dc6/src/index.ts#L56-L60
今までは

  • strategy
  • request
  • options

の 3 つを受け取ることができていましたが、options はなくなり

  • strategy
  • request

の 2 つとなりました。

簡単に変更点が分かったので v4 で Magic Link の実装を試みます。

実装にあたって元々使われていた remix-auth-email-link の実装を覗いてみます。
https://github.com/pbteja1998/remix-auth-email-link

Magic Link は入力されたメールアドレスにトークンを送り、メールに届いたリンクにアクセスすると認証が始まります。
authenticateメソッドの処理を見ると、その両方が実装されています。

メール送信:
https://github.com/pbteja1998/remix-auth-email-link/blob/1c08123c3b7ff8a1c23944731ebd45296e0f010d/src/index.ts#L206-L258
認証:
https://github.com/pbteja1998/remix-auth-email-link/blob/1c08123c3b7ff8a1c23944731ebd45296e0f010d/src/index.ts#L260-L299

なので v3 ではメールの送信と認証の処理はどちらもauthenticateを使って以下のように書きます。

return await authenticator.authenticate("email-link", request, {
  successRedirect: "/",
});

内部で最終的に throw しているのは、セッションを書き込んで cookie を更新したレスポンスを返したいがauthenticate()の返り値が User 型なので型エラーを起こさないためかと思われます。荒技に見えますが、remix-auth の他の Strategy を提供しているライブラリでもこの実装は見かけます。

v4 への対応で問題になる箇所

セッションストレージの処理を切り離せば動かせそうというのは分かっているので上記を v4 で使えるようにすべく、認証の処理でセッションの読み書きをしているところの対応を考えてみます。

まずはこちら
https://github.com/pbteja1998/remix-auth-email-link/blob/1c08123c3b7ff8a1c23944731ebd45296e0f010d/src/index.ts#L264
セッションからマジックリンクを読み取っています。
authenticateは引数で sessionStorage を受け取ることはできないため少し困りますが Request オブジェクトを受け取ることができるため、body かどこかしらに入れて受け取ることはできそうですね。
では次
https://github.com/pbteja1998/remix-auth-email-link/blob/1c08123c3b7ff8a1c23944731ebd45296e0f010d/src/index.ts#L291-L298
単純なセッションの破棄と書き込みなので、 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 させれば良さそうです。

ではメール送信でセッションの読み書きを行っている箇所を見てみます。
https://github.com/pbteja1998/remix-auth-email-link/blob/1c08123c3b7ff8a1c23944731ebd45296e0f010d/src/index.ts#L234-L241

  • 暗号化したマジックリンク
  • メールアドレス

の 2 つをセッションに追加しています。
ここではこの 2 つを返し、外側で書き込みとレスポンスを行えばよさそう、と思うのですが、authenticateの返り値の型はPromise<User>です。v4 に上がってauthenticate内で Response を返さなくてよくなった以上、正常な値を型を誤魔化すために throw させるのは気が引けます。
メソッド内でセッションを書き込んでリダイレクトさせるとしても、v4 で引数が減ったので v3 でリダイレクト先などを設定していた options を受け取ることはできなくなっています。
remix-authauthenticateの型
https://github.com/sergiodxa/remix-auth/blob/d4eb06d6362a8e67bf137c96ce7a4a503d8e2dc6/src/index.ts#L56-L60

実装方針

ここまでで以下のことが分かりました。

  • 認証はそのままでよさそう
  • メール送信処理内でセッションの書き込みを行いたいが v4 のauthenticateの設計上無理がある

これらを踏まえてメールの送信用に新しくクラスを作る方針としました。
v4 ではメール送信処理のやりたいことに対して引数と返り値が合っていませんし、1 つの関数が 2 つの目的を持っているのは汚いので。

モジュール作成

処理自体はほとんど remix-auth-email-link から持ってきています。
方針としては Strategy とメール送信の 2 つのクラスを作り、それらをまとめて初期化する関数を作ります。

完成系はこちらに用意しました。
https://github.com/sendo-kakeru/react-router7-remix-auth4-magic-link/tree/main/libs/magic-link

Strategy を作成

Strategy についてはほとんど remix-auth-email-link のままで、認証に不要なメソッドなどを削除しています。
作成したクラスは配布するわけではないので、汎用性が高すぎるオプションは削ったりデフォルト値を変えるなどしています。

https://github.com/sendo-kakeru/react-router7-remix-auth4-magic-link/blob/main/libs/magic-link/src/strategy.ts

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,
  })
);

送信用のクラスを作成

https://github.com/sendo-kakeru/react-router7-remix-auth4-magic-link/blob/main/libs/magic-link/src/sendToken.ts

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 つのクラスを初期化する関数の作成

https://github.com/sendo-kakeru/react-router7-remix-auth4-magic-link/blob/main/libs/magic-link/index.ts
プロパティを全て受け取り、インスタンスを作成して export しています。
他の Strategy モジュールと少し使い方が違いますが、これが一番綺麗かと思います。

作成したクラスを使って実際に認証フローを作成します。
https://github.com/sendo-kakeru/react-router7-remix-auth4-magic-link
こちらのリポジトリで動くものを用意していますので、要点だけ抜粋します。

インスタンスの初期化から。

app/features/auth/email-link-strategy.server.ts
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 つのクラスのインスタンスを作成してエクスポートしています。

次にメール送信エンドポイント

app/routes/api.auth.email-link.tsx
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");
  }
}

認証エンドポイント(メールに送られるリンク先)

app/routes/api.auth.email-link.callback.tsx
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 を新たに作成して渡しています。

ログインページ

app/routes/login.tsx
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