⚙️

Cloudflare Workers + KV で OAuth2.0 クライアント

2023/07/16に公開
2

最近 Web 上で写真をサッと管理するためのアプリケーションを Cloudflare 上で構築しているのですが、その際に Cloudflare Workers, KV を用いて GitHub アカウントを用いたアクセス制御を行う機会があったのでそのメモです。

認証の流れ

GitHub アカウントと連携する場合は OAuth 2.0 を利用するため、クライアント側では次の流れを踏みます。

  1. state パラメータを生成し、https://github.com/login/oauth/authorize?client_id=<id>&state=<state>(認可エンドポイント)へ遷移
  2. GitHub 上の認可画面で、ユーザにアカウント連携の承認を求める。これが許可されると、事前に指定した(Workers 上の)コールバック URL にリダイレクトされる
  3. Workers:state パラメータを検証した上で、一時コード(認可コード)を用いてアクセストークンを取得
  4. 取得したアクセストークンを用いてユーザ情報を取得
  5. セッション ID を発行し、Cookie にセット

発行したセッションは Workers KV に保存します。Free plan の上限がストレージ 1 GB、読み取り 10 万回/日、書き込み 1,000 回/日であるため、個人利用の範疇であれば無料枠に十分収まりそうです。

準備

GitHub OAuth アプリの登録

GitHub の Developer Settings → OAuth Apps(https://github.com/settings/developers)から OAuth アプリケーションを登録し、Client ID, Client secrets を取得します。Authorization callback URL の欄には、http://***..workers.dev/callback を設定しておきます。

環境変数の設定

取得した Client ID, Client secrets を 環境変数として登録します。通常の変数は wrangler.toml に、シークレットは .dev.vars に記述します。また、KV の情報もあわせて設定します。

wrangler.toml
name = "oauth-test"
main = "src/index.ts"
compatibility_date = "2023-07-16"
kv_namespaces = [{ binding = "STORE_KV", id = <id>, preview_id = <preview_id> }]

[vars]
GITHUB_CLIENT_ID = <github_client_id>
.dev.vars
GITHUB_CLIENT_SECRET=<github_client_secret>

実装

実装は GitHub 上に公開しています。

https://github.com/inaniwaudon/oauth-test

ウルトラファストな Web フレームワークであるところの Hono で実装します。バリデーションには zod をミドルウェアでの @hono/zod-validator と組み合わせて使用します。

yarn add hono @hono/zod-validator zod uuid

先程取得した環境変数をバインディングします。これにより c.env を通じて環境変数が取得可能となります。

bindings.ts
export type Bindings = {
  STORE_KV: KVNamespace;
  GITHUB_CLIENT_ID: string;
  GITHUB_CLIENT_SECRET: string;
};

declare global {
  function getMiniflareBindings(): Bindings;
}

サインイン

/signin にアクセスがあった際は https://github.com/login/oauth/authorize?client_id=<id> にリダイレクトするように設定します。

(2023/07/18 追記)
ritou 様より、CSRF 対策として state パラメータを生成・検証する必要があることをご指摘いただきました。お詫びして訂正いたします。

ランダムな値を生成して Cookie に保存した上で、認可エンドポイントへのリダイレクト時にパラメータとして ?state=xxx と付与することで、コールバック URL に遷移した際に同一の値が state パラメータとして付与されて返ってきます。このときにパラメータとして渡された値と、ブラウザに保存した値が同一であるかを検証することで、攻撃者のアカウントに意図せずサインインしてしまう事態を防ぎます。

index.ts
import { Hono } from "hono";
import { getCookie, setCookie } from "hono/cookie";
import * as uuid from "uuid";
import callback from "./callback";
import { Bindings } from "./bindings";

const app = new Hono<{ Bindings: Bindings }>();
app.get("/signin", async (c) => {
  const state = uuid.v4();
  setCookie(c, "state", state, {
    httpOnly: true,
    secure: true,
    sameSite: "None",
    path: "/",
  });
  return c.redirect(
    `https://github.com/login/oauth/authorize?client_id=${c.env.GITHUB_CLIENT_ID}&state=${state}`,
    302
  );
});
app.route("/callback", callback);
app.fire();
export default app;

コールバック

GitHub 上で確認画面が表示され、ユーザが同意した場合には /callback に遷移します。
この際の処理としては以下の実装となります。

まずクエリとして渡された一時コード(code)を基にアクセストークンを取得し、続いてアクセストークンを使用してユーザ情報を取得します。
最後にセッション ID を発行し、サーバ側では KV に、ブラウザ側では HttpOnly; Secure; sameSite=None を指定して Cookie に保存します。今回はセッションの有効期限を 60 * 60 * 24 = 1 日間に設定しています。

なお、GitHub API を利用する際はリクエストヘッダに User-Agent:(アプリケーション名等) を含める必要があります。

callback.ts
import { Hono } from "hono";
import { zValidator } from "@hono/zod-validator";
import { getCookie, setCookie } from "hono/cookie";
import { v4 as uuidv4 } from "uuid";
import z from "zod";
import { Bindings } from "./bindings";

const GITHUB_ACCESS_TOKEN_URL = "https://github.com/login/oauth/access_token";
const GITHUB_USER_URL = "https://api.github.com/user";

interface GitHubAccessTokenRequest {
  access_token?: string;
  scope?: string;
  token_type?: string;
}

const auth = new Hono<{ Bindings: Bindings }>();
const paramSchema = z.object({
  code: z.string(),
  state: z.string(),
});

auth.get(
  "/",
  zValidator("query", paramSchema, (result, c) => {
    if (!result.success) {
      return c.text("Bad Request", 400);
    }
  }),
  async (c) => {
    const param = c.req.valid("query");
    
    // CSRF measure
    const state = getCookie(c, "state")!;
    if (state === undefined || state !== param.state) {
      return c.text("Invalid state.", 400);
    }
    
    try {
      // get an access token
      const accessTokenResponse = await fetch(GITHUB_ACCESS_TOKEN_URL, {
        method: "POST",
        headers: {
          Accept: "application/json",
          "Content-Type": "application/json",
        },
        body: JSON.stringify({
          client_id: c.env.GITHUB_CLIENT_ID,
          client_secret: c.env.GITHUB_CLIENT_SECRET,
          code: param.code,
        }),
      });
      if (!accessTokenResponse.ok) {
        return c.text("Failed to get an access token", 500);
      }
      const accessTokenJson: GitHubAccessTokenRequest =
        await accessTokenResponse.json();
      if (!accessTokenJson.access_token) {
        return c.text("Failed to get an access token", 500);
      }

      // get a user name
      const userResponse = await fetch(GITHUB_USER_URL, {
        headers: {
          Accept: "application/vnd.github+json",
          Authorization: `Bearer ${accessTokenJson.access_token}`,
          "User-Agent": "oauth-test",
          "X-GitHub-Api-Version": "2022-11-28",
        },
      });
      if (!userResponse.ok) {
        return c.text("Failed to get the user info", 500);
      }
      const userJson: { [key in string]?: string } = await userResponse.json();
      const userName = userJson.login;
      if (!userName) {
        return c.text("Unauthorized", 401);
      }

      // start a session
      const ttl = 60 * 60 * 24;
      const sessionId = uuidv4();
      setCookie(c, "session_id", sessionId, {
        httpOnly: true,
        secure: true,
        sameSite: "None",
        maxAge: ttl,
        path: "/",
      });
      await c.env.STORE_KV.put(sessionId, userName, {
        expirationTtl: ttl,
      });
      return c.redirect("/test", 302);
    } catch (e) {
      console.log(e);
      c.text(`Internal Server Error: ${e}`, 500);
    }
  }
);

export default auth;

エンドポイントの認可

試しに /test にエンドポイントを生やしてみます。Cookie 内にsession_id が存在し、かつ KV 内にも同一の値が存在する場合にのみアクセスを許可し、その他の場合は Unauthorized とします。

index.ts
app.get("/test", async (c) => {
  const sessionId = getCookie(c, "session_id");
  if (!sessionId) {
    return c.text("Unauthorized", 401);
  }
  const userName = await c.env.STORE_KV.get(sessionId);
  if (!userName) {
    return c.text("Unauthorized", 401);
  }
  return c.text(`Welcome, ${userName}`);
});

一連の処理を Middleware として切り出せば、簡単に認可処理を挿入することができます。

index.ts
const authorize: MiddlewareHandler<{
  Bindings: Bindings;
  Variables: { userName: string };
}> = async (c, next) => {
  const sessionId = getCookie(c, "session_id");
  if (!sessionId) {
    return c.text("Unauthorized", 401);
  }
  const userName = await c.env.KV.get(sessionId);
  if (!userName) {
    return c.text("Unauthorized", 401);
  }
  c.set("userName", userName);
  await next();
};

app.get("/", authorize, async (c) => {
  return c.text(`Welcome, ${c.var.userId}`);
});

サインアウト

maxAge を負の値にした状態で session_id を再度 Cookie に保存し、KV から当該 session_id を削除することでサインアウトが完了します。

index.ts
app.post("/signout", (c) => {
  const sessionId = getCookie(c, "session_id")!;
  setCookie(c, "session_id", "", {
    httpOnly: true,
    secure: true,
    sameSite: "None",
    maxAge: -60,
    path: "/",
  });
  c.env.STORE_KV.delete(sessionId);
  return c.text("Logouted", 200);
});

Discussion

ritouritou

ID連携のためのプロトコルを用いて認証機能を実装する際には、いわゆるCSRF対策が必要となります。
GItHubのOAuth 2.0を利用する場合、/signinから認可エンドポイントにリダイレクトする際にstateパラメータというものを生成、保存しておき、/callback にてその値が認可レスポンスに含まれる値と一致することを検証する必要があります。
動作確認目的であれば問題ありませんが、Productionレベルでの利用の際は重大な脆弱性とみなされますのでこの投稿を参考にされる開発者の方のためにも、対応もしくは一言記載をいただけると助かります。

https://atmarkit.itmedia.co.jp/ait/articles/1710/24/news011_2.html

いなにわうどんいなにわうどん

この度は的確なご指摘を賜り、誠にありがとうございます。
記事中の CSRF 対策に不備があったことをお詫びいたします。
記事中にその旨を追記し、ソースコードに関しても state パラメータを検証するように変更いたしました。