こんにちは。新卒2年目のrksmskです。 今回は認証ライブラリを用いず、SolidStartでOAuth2.0認証クライアントを基本実装して クラウド ストレージサービスであるBoxを利用できるようになるまでをまとめた記事となります。 よろしくお願いします。 モチベーション 環境 準備 - SolidStart 準備 - Box 実装 API ページ ①&② アクセストークン発行用の承認トークンを取得するため、認証サイトにリダイレクトする サーバー側 クライアント側 ④&⑤ 承認コードを受け取り、受け取った承認コードを使用してアクセストークンを取得する サーバー側 ⑥ アクセストークンを使用して、認証ユーザーの情報取得を行い、アクセストークンと認証ユーザー情報をセッションに格納する サーバー側 クライアント側 +α Boxにアップロードしたファイルの情報をAPIから取得して、一覧表示する サーバー側 クライアント側 各画面一覧 ログイン画面 認証画面(外部サイト) ホーム画面 まとめ モチベーション 本記事は元々、SolidJSのメタ フレームワーク である SolidStart と、Next.js以外のウェブ フレームワーク でも扱えるように開発を進めており、NextAuth.jsから最近名前を変えた Auth.js を組み合わせて、 SolidStart + Auth.jsによるOAuth2.0認証付き クラウド ストレージ管理アプリ を作る構想でした。 ですが、 Google Cloud Platform は動作することが確認できたものの、 Dropbox や Box といったその他の クラウド ストレージサービスが一筋縄では動かなかったので(もしご存じの方がいらっしゃったら是非教えてください!)、「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 install と pnpm 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/callback の API が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 の型定義等は行いませんでしたが、もしライブラリを使わない実装を検討している方は是非そちらも実装してみてください。 エンジニア 中途採用 サイト ラク スでは、エンジニア・デザイナーの 中途採用 を積極的に行っております! ご興味ありましたら是非ご確認をお願いします。 https://career-recruit.rakus.co.jp/career_engineer/ カジュアル面談お申込みフォーム どの職種に応募すれば良いかわからないという方は、カジュアル面談も随時行っております。 以下フォームよりお申込みください。 rakus.hubspotpagebuilder.com ラク スDevelopers登録フォーム https://career-recruit.rakus.co.jp/career_engineer/form_rakusdev/ イベント情報 会社の雰囲気を知りたい方は、毎週開催しているイベントにご参加ください! ◆TECH PLAY techplay.jp ◆connpass rakus.connpass.com