RAKUS Developers Blog | ラクス エンジニアブログ

株式会社ラクスのITエンジニアによる技術ブログです。

OAuth2.0認証クライアントを自前実装で導入してみる【SolidStart+OAuth2.0+Box】

こんにちは。新卒2年目のrksmskです。

今回は認証ライブラリを用いず、SolidStartでOAuth2.0認証クライアントを基本実装してクラウドストレージサービスであるBoxを利用できるようになるまでをまとめた記事となります。

よろしくお願いします。

モチベーション

本記事は元々、SolidJSのメタフレームワークであるSolidStartと、Next.js以外のウェブフレームワークでも扱えるように開発を進めており、NextAuth.jsから最近名前を変えたAuth.jsを組み合わせて、SolidStart + Auth.jsによるOAuth2.0認証付きクラウドストレージ管理アプリを作る構想でした。

ですが、Google Cloud Platformは動作することが確認できたものの、DropboxBoxといったその他のクラウドストレージサービスが一筋縄では動かなかったので(もしご存じの方がいらっしゃったら是非教えてください!)、「OAuth2.0の勉強も兼ねて自前実装してみよう」という運びとなりました。

環境

下記の環境を前提としています。

  • Node.js@18.13.0
  • pnpm@7.25.1

準備 - SolidStart

まず、SolidStart用のディレクトリを作成します。pnpm solid createと入力すると、CLIで簡単にテンプレートディレクトリを作成することが出来ます。

$ pnpm create solid
../../.pnpm-store/v3/tmp/dlx-3444        | Progress: resolved 1, reused 0, downloaded 0, added 0
...(略)
? Which template do you want to use? » - Use arrow-keys. Return to submit.
>   bare
    hackernews
    todomvc
    with-auth
    with-mdx
    with-prisma
    with-solid-styled
    with-tailwindcss
    with-vitest
    with-websocket

√ Which template do you want to use? » bare
? Server Side Rendering? » (Y/n)
√ Server Side Rendering? ... yes
? Use TypeScript? » (Y/n)Y
√ Use TypeScript? ... yes
found matching commit hash: 82901a8a21b24a90cbb740b304ba307d167e5d94
...(略)
✔ Copied project files

コマンドを入力してしばらくすると、テンプレート作成のために三つ質問が行われます。

一つ目の質問であるWhich template do you want to use?(訳:どのテンプレートを使用しますか?)では、最もシンプルなテンプレートであるbareを選びます。

二つ目の質問であるServer Side Rendering?(訳:SSRの機能を使用しますか?)ではYを入力します。

最後の質問であるUse TypeScript?(訳:TypeScriptを使用しますか?)では、今回はYを入力します。

処理が完了すると、テンプレート作成後に行うことがコンソール上に記載されるので、その通りにpnpm installpnpm run dev --openを実行します。 すると、ひな形アプリが立ち上がります。簡単ですね。

アプリ画面

最後に、開発時とビルド時のポート番号を合わせておくと後の作業の都合がよいので、vite.config.tsを下記のように編集しておきましょう。

vite.config.ts

import solid from "solid-start/vite";
import { defineConfig } from "vite";

export default defineConfig({
  plugins: [solid()],
+  server: {
+   port: 3000,
+  },
});

以上でSolidStartの開発準備が整いました。

準備 - Box

続いて、BoxのOAuth2.0認証の準備を整えていきましょう。

Boxをご存知ない方に軽く説明すると、Boxとはファイル管理機能とセキュリティ機能に優れたクラウドストレージサービスです。嬉しいことに、無料枠でもクレジットカード登録なしで10GB分ファイルアップロードを行うことが出来ます。

BoxのPricingのページに飛び、今回は「Individual Free」の「Sign Up」ボタンをクリックします。

そして、名前、メールアドレス、パスワードの欄を入力し、hCaptchaをクリックして「開始する」ボタンをクリックします。

すると、登録したメールアドレスに認証メールが送られてくるので、認証ボタンをクリックします。

これでアカウント登録は完了です。試しにログインしてみると、自身のマイページが閲覧できるようになっていることが確認できます。

ここからは、Box上でのOAuth2.0用のアプリ作成を行っていきます。

マイページ左下の「Dev Console」ボタンをクリックし、開発者ページを開きます。

「Create New App」ボタンをクリックし、Authentication Methodで「Auth2.0」を選択し、App Nameに適当な名前を入れ、「Create App」ボタンをクリックします。

これでOAuth2.0用のアプリケーションが用意できました。最後に各種設定を行います。

「Configuration」タブをクリックし、OAuth 2.0 Redirect URIを「http://localhost:3000/api/auth/callback」に変更し、Application Scopesの「Write all files and folders stored in Box」と「Manage users」にチェックを入れて保存します。

最後にClient IDとClient Secretを手元のどこかにメモしておきましょう(後で使います)。

以上でBox側の設定は完了です。

実装

OAuth2.0認証クライアントの実装に入る前に、ざっくりですがOAuth2.0の仕組みを記載します。

この図の①から⑥の手順に沿って実装していきます。

本格的な実装に入る前に、出来上がりの全体像を把握しておきましょう。最終的なsrcディレクトリ下は下記のような構成になります。

それぞれのファイルの役割は下記となります。

API

  • src/routes/api/auth/callback/index.ts
    • OAuth2.0認証での承認コード取得時のコールバック先のAPI。アクセストークンとユーザー情報の取得、セッションへの保存を行う。
  • src/routes/api/auth/login/index.ts
    • ログイン処理用のAPI。OAuth2.0認証の承認先へのリダイレクトURLを返す。
  • src/routes/api/auth/logout/index.ts
    • ログアウト処理用のAPI。セッションをクリアしてログイン画面にリダイレクトする。
  • src/routes/api/file/index.ts
    • ファイル一覧取得API。Boxからファイル一覧を取得し、その情報を返す。
  • src/routes/api/user/me/index.ts
    • ユーザー名取得API。セッション内に保管してあるユーザー名を返す。
  • src/routes/session.server.ts
    • セッション管理用。

ページ

  • src/routes/login/index.tsx
    • ログインページ。
  • src/routes/index.tsx
    • ホームページ。ログイン後、閲覧可能で、Boxにアップロードしているファイルの一覧を表示する。ログイン前に表示した場合、ログイン画面に遷移する。

それでは、実装に入っていきましょう。なお、今回はHTTP通信の記載の簡素化のため、axiosを用いています。Fetch APIでも同様の実装が可能ですが、本記事を手を動かしながら試す場合には、事前に下記コマンドを実行してください。

pnpm install axios

①&② アクセストークン発行用の承認トークンを取得するため、認証サイトにリダイレクトする

サーバー側

まず、サーバー側を実装します。src/routes/api/auth/login/index.tsを下記の内容で作成します。

src/routes/api/auth/login/index.ts

export async function GET() {
  // クエリパラメータに変換
  const query = new URLSearchParams({
    client_id: import.meta.env.VITE_BOX_ID,
    client_secret: import.meta.env.VITE_BOX_SECRET,
    response_type: "code",
  });
  // リダイレクト先をLocationに入れて返却する
  return new Response(null, {
    status: 200,
    headers: {
      Location: `https://account.box.com/api/oauth2/authorize?${query.toString()}`,
    },
  });
}

SolidStartではsrc/routes/api下にファイルを作成すると、ファイルパスがそのままAPIのエンドポイントとなります。そのファイル内で大文字のGET/POST/PUT/PATCH/DELETEを関数名にした関数を作成すると、その関数がそのままそのエンドポイントでのHTTPメソッドとなります。

Box認証サイトのURLはhttps://account.box.com/api/oauth2/authorizeにBox側の作業でメモしたClient IDとClient Secret、レスポンスタイプをクエリパラメータに付与したものなので、その情報をレスポンスのLocationヘッダーに付与して返却しています。

Client IDとClient Secretは公開してはいけない情報なので、.envファイルに記載します。その際に、型補完がきくようにvite-env.d.tsファイルへ記載するのと、誤ってGitにアップしてしまわないように忘れずに.gitignoreに.envを追記しておきます。

.env

VITE_BOX_ID=***
VITE_BOX_SECRET=***

vite-env.d.ts

interface ImportMetaEnv {
  readonly VITE_BOX_ID: string;
  readonly VITE_BOX_SECRET: string;
}

interface ImportMeta {
  readonly env: ImportMetaEnv;
}

.gitignore

dist
.solid
.output
.vercel
.netlify
+ .env
netlify

# dependencies
/node_modules

# IDEs and editors
/.idea
.project
.classpath
*.launch
.settings/

# Temp
gitignore

# System Files
.DS_Store
Thumbs.db

クライアント側

続いて、クライアント側を実装します。src/routes/login/index.tsxを下記の内容で作成します。

src/routes/login/index.tsx

import { Title } from "solid-start";
import axios from "axios";

export default function Login() {
  return (
    <main>
      <Title>Login</Title>
      <h1>Hello world!</h1>
      <button
        onClick={() => {
          axios.get("http://localhost:3000/api/auth/login").then((res) => {
            window.location.href = res.headers["location"] || "/";
          });
        }}
      >
        login
      </button>
    </main>
  );
}

SolidStartではサーバー側と同様に、src/routes下にファイルを作成すると、ファイルパスがそのままページのURLとなります。

内容はシンプルで、ログインボタンを押したら先ほど作成したサーバー側の/api/auth/loginにGETリクエストを行い、成功のレスポンスが帰ってきたらLocationヘッダーのURLに遷移するというものです。

前述したように、/api/auth/loginはBoxの認証サイトへのURLをLocationヘッダーに含めているので、これでログインボタン押下時に認証サイトにリダイレクトされるようになりました。

なお、現状だと動作確認しづらいので、src/root.tsxにログインページへの遷移先を配置しておきましょう。

src/root.tsx

// @refresh reload
import { Suspense } from "solid-js";
import {
  A,
  Body,
  ErrorBoundary,
  FileRoutes,
  Head,
  Html,
  Meta,
  Routes,
  Scripts,
  Title,
} from "solid-start";
import "./root.css";

export default function Root() {
  return (
    <Html lang="en">
      <Head>
        <Title>SolidStart - Bare</Title>
        <Meta charset="utf-8" />
        <Meta name="viewport" content="width=device-width, initial-scale=1" />
      </Head>
      <Body>
        <Suspense>
          <ErrorBoundary>
            <A href="/">Index</A>
-            <A href="/about">About</A>
+            <A href="/login">Login</A>
            <Routes>
              <FileRoutes />
            </Routes>
          </ErrorBoundary>
        </Suspense>
        <Scripts />
      </Body>
    </Html>
  );
}

④&⑤ 承認コードを受け取り、受け取った承認コードを使用してアクセストークンを取得する

サーバー側

続いて、認証サイトでの認証後の処理を作成していきます。src/routes/api/auth/callback/index.tsファイルを下記の内容で作成します。

src/routes/api/auth/callback/index.ts

import { APIEvent } from "solid-start";
import axios from "axios";

export async function GET({ request }: APIEvent) {
  // クエリパラメータに含まれる承認コード取得用
  const query = new URL(request.url).searchParams;
  const accessToken = await axios
    .post("https://api.box.com/oauth2/token", {
      client_id: import.meta.env.VITE_BOX_ID,
      client_secret: import.meta.env.VITE_BOX_SECRET,
      code: query.get("code"),
      grant_type: "authorization_code",
    })
    .then((res) => {
      return res.data.access_token;
    });
}

BoxのOAuth2.0認証用APIは、エンドポイントhttps://api.box.com/oauth2/tokenにPOSTメソッドで下記の情報をbodyに含めてあげるとアクセストークンを返してくれます(詳しくはBox APIリファレンス参照)。

  • client_id…アプリで発行したClient ID
  • client_secret…アプリで発行したClient Secret
  • code…承認コード
  • grant_type…認証のリクエスト方式。アクセストークン取得時はauthorization_codeを指定

承認コードについては、Boxの事前準備でRedirect URIを「http://localhost:3000/api/auth/callback」に変更しているため、認証後は/api/auth/callbackAPIがGETリクエストで呼ばれ、そのクエリパラメータに承認コード(code)が含まれています。

なので、リクエストURLに含まれるクエリパラメータから承認コードを取得し、その情報とClient ID、Client Secretをbodyに含めることでアクセストークンを取得することが出来ます。これで、Box APIを使用する準備が整いました。

⑥ アクセストークンを使用して、認証ユーザーの情報取得を行い、アクセストークンと認証ユーザー情報をセッションに格納する

サーバー側

アクセストークンが取得できたので、そのままBox APIから認証ユーザーの情報を取得してみましょう。src/routes/api/auth/callback/index.tsファイルを下記の内容に変更します。

src/routes/api/auth/callback/index.ts

import { APIEvent } from "solid-start";
import axios from "axios";

export async function GET({ request }: APIEvent) {
  // クエリパラメータに含まれる承認コード取得用
  const query = new URL(request.url).searchParams;
  const accessToken = await axios
    .post("https://api.box.com/oauth2/token", {
      client_id: import.meta.env.VITE_BOX_ID,
      client_secret: import.meta.env.VITE_BOX_SECRET,
      code: query.get("code"),
      grant_type: "authorization_code",
    })
    .then((res) => {
      return res.data.access_token;
    });
+  const user = await axios
+    .get("https://api.box.com/2.0/users/me", {
+      headers: {
+        authorization: `Bearer ${accessToken}`,
+        contentType: "application/json",
+      },
+    })
+    .then((res) => {
+      return res.data;
+    });
}

リクエストのAuthorizationヘッダーにBearer ${アクセストークン}とすることで、そのアクセストークンが有効であればBox APIを利用することが出来ます。これで認証ユーザーの情報が取得できました。

以上のアクセストークン、認証ユーザーの情報をアプリ内で認証中は使い回したので、これらをセッション内に格納します。SolidStartのSessionsページを参考に、src/routes/session.server.tsを下記の内容で作成します。

src/routes/session.server.ts

import { redirect } from "solid-start/server";
import { createCookieSessionStorage } from "solid-start/session";

const storage = createCookieSessionStorage({
  cookie: {
    name: "_session",
    secure: process.env.NODE_ENV === "production",
    sameSite: "lax",
    path: "/",
    maxAge: 60 * 60 * 24,
    httpOnly: true,
  },
});

export function getUserSession(request: Request) {
  return storage.getSession(request.headers.get("Cookie"));
}

export async function logout(request: Request) {
  const session = await storage.getSession(request.headers.get("Cookie"));
  return redirect("/login", {
    headers: {
      "Set-Cookie": await storage.destroySession(session),
    },
  });
}
export async function createUserSession(
  token: string,
  userName: string,
  redirectTo: string
) {
  const session = await storage.getSession();
  session.set("token", token);
  session.set("userName", encodeURIComponent(userName));

  const cookie = await storage.commitSession(session);

  return redirect(redirectTo, {
    headers: {
      "Set-Cookie": cookie,
    },
  });
}

まず、createCookieSessionStorage関数を使用してセッションストレージを作成します。

次に、作成したセッションストレージにアクセストークンとユーザー名を保存し、指定したリダイレクト先に遷移するcreateUserSession関数を作成します。なお、ユーザー名の保存時には日本語でも保存可能なようにURIエンコードをかけています。

最後に、セッション情報を破棄するlogout関数と、セッション情報を取得するgetUserSession関数を作成します。

これで、セッション周りの設定が完了しました。作成したcreateUserSession関数を利用して、アクセストークン、認証ユーザー取得時にそれらをセッションに格納します。

src/routes/api/auth/callback/index.ts

import { APIEvent } from "solid-start";
import axios from "axios";
+import { createUserSession } from "~/routes/session.server";

export async function GET({ request }: APIEvent) {
  // クエリパラメータに含まれる承認コード取得用
  const query = new URL(request.url).searchParams;
  const accessToken = await axios
    .post("https://api.box.com/oauth2/token", {
      client_id: import.meta.env.VITE_BOX_ID,
      client_secret: import.meta.env.VITE_BOX_SECRET,
      code: query.get("code"),
      grant_type: "authorization_code",
    })
    .then((res) => {
      return res.data.access_token;
    });
  const user = await axios
    .get("https://api.box.com/2.0/users/me", {
      headers: {
        authorization: `Bearer ${accessToken}`,
        contentType: "application/json",
      },
    })
    .then((res) => {
      return res.data;
    });
+return createUserSession(accessToken, user.name, "http://localhost:3000/");
}

以上でセッションへの格納処理が完成しました。これにより、ログイン後はリクエストのクッキーに含まれるセッションIDからアクセストークンとユーザー情報を取り出し、利用することが可能になります。セッションIDから認証ユーザーのユーザー名を返すAPIは下記のようになります。

src/routes/api/user/me/index.ts

import { APIEvent, json } from "solid-start";
import { getUserSession } from "~/routes/session.server";

export async function GET({ request }: APIEvent) {
  const session = await getUserSession(request);
  let userName = await session.get("userName");
  userName = userName ? decodeURIComponent(userName) : null;

  return json({ userName });
}

また、セッション情報を削除するAPIは下記のようになります。

src/routes/api/auth/logout/index.ts

import { APIEvent } from "solid-start";
import { logout } from "~/routes/session.server";

export async function POST({ request }: APIEvent) {
  return await logout(request);
}

クライアント側

クライアント側では、ページ表示時に認証ユーザーのユーザー名を取得し、ユーザー名が取得できなければ(認証前)ログイン画面へ遷移、ユーザー名が取得できればユーザー名とログアウトボタンを表示する処理を作成してみましょう。src/routes/index.tsxを下記の内容で作成します。

src/routes/index.tsx

import { Title, useNavigate, useRouteData } from "solid-start";
import axios from "axios";
import { createServerData$, redirect } from "solid-start/server";

export function routeData() {
  return createServerData$(async (_, { request }) => {
    const user = (
      await axios.get("http://localhost:3000/api/user/me", {
        headers: { Cookie: request.headers.get("Cookie") },
      })
    ).data;

    if (!user.userName) throw redirect("/login");

    return { userName: user.userName };
  });
}

export default function Home() {
  const serverData = useRouteData<typeof routeData>();
  const navigate = useNavigate();

  return (
    <main>
      <Title>Hello World</Title>
      <h1>Hello world!</h1>
      <p>Hello, {serverData()?.userName}!</p>
      <button
        onClick={() => {
          axios.post("http://localhost:3000/api/auth/logout").finally(() => {
            navigate("/login");
          });
        }}
      >
        logout
      </button>
    </main>
  );
}

SolidStartではレンダリング前に情報を取得してコンポーネントにその情報を渡す場合はrouteData関数(コンポーネントへ情報を渡す)とuseRouteData関数(情報を受け取る)を用い、加えてrouteData関数の内部でサーバーサイドからデータを取得する場合にはcreateServerData$関数を使用します(参考)。

今回は、 1. /api/user/meにGETリクエストを投げてユーザー名を取得 1. 取得したユーザー名がnullの場合はログイン画面にリダイレクト の処理をrouteData関数で記載しています。そして、useRouteData関数でコンポーネントに情報を渡し、画面上に情報を表示しています。

ログアウトボタンでは、ボタン押下時に/api/auth/logoutにPOSTリクエストを投げることでセッション情報を削除し、処理完了後にログインページに遷移する処理を記述しています。

+α Boxにアップロードしたファイルの情報をAPIから取得して、一覧表示する

サーバー側

Box APIを使用できるようになったので、せっかくなのでファイル情報の一覧取得を行ってみましょう(ファイルはBox側で事前にアップロードしておいてください)。

src/routes/api/file/index.tsを下記の内容で作成します。

src/routes/api/file/index.ts

import { APIEvent } from "solid-start";
import { json, redirect } from "solid-start/server";
import axios from "axios";
import { getUserSession } from "~/routes/session.server";

export async function GET({ request }: APIEvent) {
  const session = await getUserSession(request);
  const accessToken = session.get("token");
  const items = await axios
    .get("https://api.box.com/2.0/folders/0/items", {
      headers: {
        authorization: `Bearer ${accessToken}`,
        contentType: "application/json",
      },
    })
    .then((res) => {
      return res.data;
    })
    .catch(() => {
      throw redirect("/login");
    });

  return json({ items: items.entries });
}

行っていることは非常にシンプルで、セッションからアクセストークンを取り出してAuthorizationヘッダーに付与し、フォルダ内の項目のリストを取得するAPIからファイル一覧を取得した後、そのまま返却しています。

クライアント側

クライアント側では、ホームページにファイル一覧を取得し、それを表示する処理を記述します。

src/routes/index.tsxを下記の内容で作成します。

src/routes/index.tsx

import { Title, useNavigate, useRouteData } from "solid-start";
import axios from "axios";
import { createServerData$, redirect } from "solid-start/server";

export function routeData() {
  return createServerData$(async (_, { request }) => {
    const user = (
      await axios.get("http://localhost:3000/api/user/me", {
        headers: { Cookie: request.headers.get("Cookie") },
      })
    ).data;

    if (!user.userName) throw redirect("/login");

+    const items = (
+      await axios.get("http://localhost:3000/api/file", {
+        headers: { Cookie: request.headers.get("Cookie") },
+      })
+    ).data;

-    return { userName: user.userName };
+    return { userName: user.userName, items: items.items };
  });
}

export default function Home() {
  const serverData = useRouteData<typeof routeData>();
  const navigate = useNavigate();

  return (
    <main>
      <Title>Hello World</Title>
      <h1>Hello world!</h1>
      <p>Hello, {serverData()?.userName}!</p>
+      <ul>
+        {serverData()?.items.map((item: any) => (
+          <li>{item.name}</li>
+        ))}
+      </ul>
      <button
        onClick={() => {
          axios.post("http://localhost:3000/api/auth/logout").finally(() => {
            navigate("/login");
          });
        }}
      >
        logout
      </button>
    </main>
  );
}

こちらも実装としてはシンプルで、ホームページ内のrouteData関数内で、/api/fileにGETリクエストを投げてファイル一覧情報を取得し、DOM内でmap関数を用いて一覧表示しています。

以上で実装は完了となります。

各画面一覧

それでは、最後にアプリケーションを立ち上げて各画面の確認を行いましょう。

pnpm run devを実行してアプリケーションを立ち上げ、http://localhost:3000にアクセスします。

ログイン画面

ログイン画面

認証前はホーム画面にアクセスしてもリダイレクトされて、ログイン画面が表示されます。画面中央の「login」ボタンをクリックします。

認証画面(外部サイト)

認証画面

「login」ボタンクリック後、Boxの認証画面にリダイレクトされます。そこでログイン情報を入力して「Authorize」ボタンをクリックし、次のページで「Grant access to Box」ボタンをクリックして認証を完了します。

ホーム画面

ホーム画面

認証完了後は、ホーム画面に遷移し、Boxでのユーザー情報とBoxにアップロードしたファイル名の一覧が表示されます。

以上でOAuth2.0認証クライアントの導入は完了です。お疲れさまでした。

まとめ

いかがだったでしょうか。私個人としては認証ライブラリを用いずに自前で実装したことで、OAuth2.0認証の理解を深めることが出来て良かったと思います。

読んでいただいている皆様にも同じように思っていただけたら光栄です。

今回はOAuth2.0認証クライアントの導入ということで、リフレッシュトークンやエラーハンドリング周りの細かい実装、APIの型定義等は行いませんでしたが、もしライブラリを使わない実装を検討している方は是非そちらも実装してみてください。


エンジニア中途採用サイト
ラクスでは、エンジニア・デザイナーの中途採用を積極的に行っております!
ご興味ありましたら是非ご確認をお願いします。
20210916153018
https://career-recruit.rakus.co.jp/career_engineer/

カジュアル面談お申込みフォーム
どの職種に応募すれば良いかわからないという方は、カジュアル面談も随時行っております。
以下フォームよりお申込みください。
rakus.hubspotpagebuilder.com

ラクスDevelopers登録フォーム
20220701175429
https://career-recruit.rakus.co.jp/career_engineer/form_rakusdev/

イベント情報
会社の雰囲気を知りたい方は、毎週開催しているイベントにご参加ください!

◆TECH PLAY
techplay.jp

◆connpass
rakus.connpass.com

Copyright © RAKUS Co., Ltd. All rights reserved.