hnwの日記

Hono上にストレージレスなログインセッション管理を実装してみた

セッションストレージなしでログインセッションを維持する仕組みを作ったので、簡単に紹介します。

先日oidc-authというHonoミドルウェアを実装して3rd-party middlewareとして採用していただきました。これは外部IDプロバイダーで認証を行ない、自前発行したJWTを毎リクエスト検証することで、サーバ側でセッションIDを記録することなくログインセッションを維持するものです。

このセッションストレージ不要という特徴はCDNエッジと親和性が高く、たとえばCloudflare Pagesで提供する静的コンテンツにGoogle認証をつける、といったことをエッジのCPUだけで実現できます。加えて、HonoのポータビリティのおかげでDeno Deployでも同じ仕組みが使えたりします。

個人的には実用性とセキュリティを両立した面白いものが作れたと考えていますが、セキュリティ面で不安を感じる人もいると思うので解説記事を書いてみます。

エッジコンピューティングとは

本題に入る前に、エッジコンピューティングについて簡単に紹介します。

Webの文脈でエッジコンピューティングと言った場合、ごく短時間だけ動くような処理をCDNエッジサーバなどユーザーの近所で動作させる仕組みを指すと思います。Cloudflare WorkersLambda@EdgeVercelなどがその代表格と言えるでしょう。

こうした環境の動作環境としてJavaScriptエンジンが多く採用されているため、エッジ上で動かすスクリプトは基本的にJavaScriptやTypeScriptで記述することになります1

ユースケースとしては簡単な処理を前提としていることが多く、どの環境でも実行時間の上限は厳しめです。例えばCloudflare Workersは実行時間の上限が50ms2です。

やや古い文書ですが、@yusukebeさんの「CDNのエッジで実行する系が面白い」を読むと背景やユースケースをより詳しく理解できると思います。

このエッジコンピューティングですが、特にWebフロントエンド界隈の方々に支持されている印象があります。私は門外漢なので想像になりますが、利用言語の親和性と実際のニーズと両方が噛み合っているということなのでしょう。特に、エッジでSSRした結果を一定期間キャッシュしておくような使い方はメリットが大きそうです。

Honoの紹介とoidc-authの利用例

Honoは@yusukebeさんが作られているTypeScript製のWebアプリケーションフレームワークです。

Honoの特徴を一言で説明すると、「事業者ごとの差分が大きいエッジ処理にポータビリティを提供するエッジ用フレームワーク」となります3。各社のエッジコンピューティング環境は基本的にJavaScript/TypeScriptで記述するものが多いのですが、それぞれの差分がかなり大きく、複数社の環境で同内容の処理を書くだけでも意外と苦労する印象です。Honoで書くことで小さい変更で別の環境でも動かすことができます。

論より証拠で、今回私が作成したoidc-authを使って静的ページにOpenID Connectで認証・認可をつけるコードを見てみましょう。Cloudflare Pagesの場合は下記のようになります。

import { Hono } from 'hono'
import { oidcAuthMiddleware, getAuth } from '@hono/oidc-auth'

const app = new Hono()

app.use('*', oidcAuthMiddleware())
app.use('*', async (c, next) => {
  // Authorize user with email address
  const auth = await getAuth(c)
  if (!auth?.email.endsWith('@gmail.com')) {
    return c.text('Unauthorized', 401)
  }
  await next()
})

app.get('*', async (c) => {
  const response = await c.env.ASSETS.fetch(c.req.raw);
  // clone the response to return a response with modifiable headers
  const newResponse = new Response(response.body, response)
  return newResponse
});

export default app

認証用のURL、クライアントIDやシークレットなどは環境変数経由で渡しています。上記の場合であればGoogle認証を経由して@gmail.comのアドレスだった場合だけ静的ページを閲覧できます。

次にDeno Deploy版を見てみましょう。

import { Hono } from 'npm:hono'
import { serveStatic } from 'npm:hono/deno'
import { oidcAuthMiddleware, getAuth } from 'npm:@hono/oidc-auth';

const app = new Hono()

app.use('*', oidcAuthMiddleware())
app.use('*', async (c, next) => {
  // Authorize user with email address
  const auth = await getAuth(c)
  if (!auth?.email.endsWith('@gmail.com')) {
    return c.text('Unauthorized', 401)
  }
  await next()
})

app.use('*', serveStatic({ root: 'public/' }))

Deno.serve(app.fetch)

異なる環境で似たコードが動くことがわかると思います。

実際の挙動やソースコードを確認したい方は下記リンクから試してみてください。

ご自身で動作確認したい場合、環境変数を最低5つ設定した上でIDプロバイダ側にコールバックURLを登録する必要があります。詳細は@hono/oidc-authをご確認ください。

JWTによるセッション管理の実装

ようやく本題です。oidc-authの実装を紹介します。

oidc-authのシーケンス図

シーケンス図を見ると少し複雑に見えますが、IDプロバイダからIDトークンを取り出すところまでは普通のOpenID Connectの認証フローです。

その後、取り出したIDトークンを元に自前でJWTを作ります。このJWTにはユーザーID4、eメールアドレス、リフレッシュトークン、リフレッシュ期限(デフォルト15分)とセッション期限(デフォルト1日)が含まれており、HS256で署名しています。

このJWTをSet-Cookieヘッダでブラウザに返し、ブラウザから送信されるJWTを毎リクエスト検証します。JWTの署名検証をパスしてJWT内のセッション期限を経過していなければセッションが有効ということになります。

上記の説明から、いわゆるセッションストレージを使っていないのがわかるかと思います。本来ならセッションストレージにおくべき情報を署名付きでブラウザのクッキーに保存し、毎回署名検証を行なっているわけです。

セキュリティにも配慮して実装しています。oidc-authではoauth4webapiというブラウザ向けのOAuthクライアント実装を利用しており、OpenID Connectの実装で必要とされるセキュリティ対策は全て実施しています5。また、クッキーのSecure属性やHttpOnly属性もちゃんと指定しています。

JWTをセッションとして使うことの是非について

5年ほど前に、SPAなどの文脈でJWTをセッション管理に使うのがいいのか悪いのかが議論になったことがあります。たとえば「どうしてリスクアセスメントせずに JWT をセッションに使っちゃうわけ?」などを読むと当時の議論がわかります。

私の理解では、セッション管理にJWTを使うべきじゃない派の主張は次のようにまとめられます。

  1. 万一JWTが漏洩したときにセッションを無効化する手段がない、JWTの有効期限切れを待つしかない
  2. ログアウト時に当該JWTを無効化できない(そういう実装が多い)
  3. 内部犯がJWT署名用の共通鍵を悪用すると任意のJWTを作って認証を回避できる
  4. JWTは巨大かつ検証コストが高いのでネットワークとCPUの無駄遣いである
  5. JWTを採用するとセッションID方式より複雑度が上がってバグを作りやすい

これらの指摘のうち、特に最初のものは致命的な問題点と言えます。一定規模の組織での運用を考えた場合、利用者がPCを紛失してJWTが悪意ある第三者に渡るシナリオは現実的な脅威です。一方、JWTは署名検証をパスすればJWTの中身を信用する仕組みなので、JWTが漏洩した場合でも管理者が無効化できないのです。

この問題に対応するため、今回実装したoidc-authではJWT内にリフレッシュトークンを格納し、比較的短いスパン(デフォルト15分)で暗黙的にトークン再発行を行っています。JWT漏洩の恐れがある場合、管理者が当該ユーザーのリフレッシュトークンを無効化すれば15分以内にアクセスできなくなるというわけです。管理者がリフレッシュトークンを無効化できるかどうかはIDプロバイダーによると思いますが、Google Workspace、Auth0、AWS Cognitoなどでは無効化可能です。

また、2つ目の指摘点への対応としてログアウト時にJWTに含まれるリフレッシュトークンを無効化しています6。これにより、JWTが仮に漏洩したとしても次回のトークンリフレッシュに失敗してJWT自体が無効になる仕組みになっています。すでに説明した通りセッション無効化を試みてから一定期間(デフォルト15分)のタイムラグを許容する前提なので、タイムラグ1分でも許せない場合はこの仕組み自体を採用できないことになります。

3つ目の指摘点も深刻な内容だと私は考えています。対策としては定期的に鍵をローテートするくらいしかないと思いますが、現状だと運用者が頑張るしかないので、oidc-auth側でも何か仕組み化したいところです。

4つ目は大変ごもっともだと思うのですが、エッジコンピューティング環境だとネットワーク転送量とエッジのCPUは安価なリソースであり、セッションストレージは相対的に高価なので許してほしい、というのが私の主張です。

5つ目も本当にその通りで、セキュリティの専門性が低い小予算のチームがJWTを使ったセッション管理を自前実装するのは一般論として危険だと思います。とはいえ、今回私は一定以上検討して作ったつもりですし、OSSの形でみんなで叩いていけば安全なものができるのではないでしょうか。そうしたノウハウを蓄積できる環境としてHonoは素晴らしいプラットフォームだと思っています。

まとめ

OpenID Connectのログインセッション維持をブラウザクッキーだけで実現するようなHonoのミドルウェアoidc-authを実装しました。CDNエッジと相性が良く、安価に認証・認可を実現できて便利だと思います。またセキュリティについても考慮しているつもりです。

とはいえoidc-authにどの程度のニーズがあるか作者自身もよくわかっていませんので、使ってみての感想やご意見をいただけると嬉しいです。

最後に、(おそらく得体のしれない状態で)oidc-authをHonoの3rd-party middlewareとして採用いただいた@yusukebeさんに感謝いたします。この記事を元にREADMEに追記して、もう少し安心して使える状態にしたいと思います。


  1. 環境によってはRustなどで記述してWebAssemblyで動作させることもできますが、最有力の選択肢とは言えない気がします
  2. フリープランだと10msとさらに制約が厳しくなります。また、50msより長時間使える別プランも提供されています。
  3. あくまで私の解釈なので、違った利点に着目しているユーザーも多いと思います
  4. IDトークンのsubフィールド
  5. oauth4webapi自体が利用者も多く継続的にメンテナンスされているライブラリで、OpenID Connectで必須とされるstateパラメータ、nonceパラメータ、code_challengeパラメータの3つの検証を標準でサポートしています。また、alg:noneを拒否するなどセキュリティ観点のベストプラクティスが実装されているように思います。
  6. 本稿サンプルコードやシーケンス図ではログアウト処理を省略していますが、ちゃんと実装してあります