Cloud RunとAuth0で作る、「組織内限定サイト」のプロトタイプ

## 概要

- Google Cloud Run上のサービスとして
- ちゃんと認証を必要とする形式で

組織限定な静的サイトを提供できたりしないかな？と思い、プロトタイピングしてみました。

### 注意書き

:warning:現時点では実装検証を主軸においたプロトタイプです。

## 動機

ドキュメントを書く時に、「reStructuredTextやMarkdownで用意した原稿からビルドしたHTMLを公開する」ということをOSS活動側ではよくやります。

この「HTMLを公開する」という過程について考えてみると、[GitHub Pages](https://docs.github.com/en/pages)を筆頭に[Read the Docs](https://readthedocs.org/)や[Netlify](https://www.netlify.com/)などがあったりします。
基本的にこれらのサービスが無料プランでも利用可能なこともあり、あまりホスティング先に困るということは起こりにくくなっています。

ところが、**「（なるべく余計なアクションは少なく）特定組織のメンバーに限定して公開したい」**という条件になると色々と面倒になってきます。

*※前提を絞りますが、組織の限定化を「Google WorkspaceなどのアカウントでSSOライクに認証できること」を目標とします*

先程出たサービスに目を向けると、このような感じになります。

- GitHub Pagesは通常のプランではサイトはグローバル公開が必須
- Read the Docsはビジネスプランがあるが、いきなり 50ドル/月のプランがスタートライン
- NetlifyはProプランが19ドル/月からだけど、パスワード認証限定
    - その上位プランだと、99ドル/月ドルと大きくジャンプアップ

もちろん十分なサイト数の想定があって十全に使いこなせるなら、ビジネス視点ではそこまで大きな額面ではないと言えるでしょう。
とは言っても、「需要が見えないけどRead the Docs for Business使いたい」はちょっと気が引けますよね。

*※「事前に需要調査しろよ」「トライアル期間あるよね？」などに関しては、ひとまず耳をふさぎます*

というわけで、

- 従量課金の枠組みで収まる [^1]
- 静的サイト以外の要件を求めない [^2]
- ただし、アクセスにソーシャル連携を必要とする [^2]

こんなサイト環境を構築できないかを試してみました。

## どう実装するか

以下のような構成になりました。

- Cloud Runのサービスとしてホスティングする
- コンテナ内ではOpenRestyを動作させ、拡張機能でOpenIDによる認証連携を実施する
- 認証連携としてAuth0を組み合わせて、ここでGoogleアカウント連携での認証をする
- Auth0での認証が通り、なおかつ条件を満たす人だけアクセス可能とする

*※OpenRestyについては、「Luaによる動的振る舞いをしやすいNginx」ぐらいの認識で利用します*

後述するようにOpenID ConnectにおけるRelying Partyの仕組みを用います。
説明を書くとそれなりにボリュームが増えてしまうので、[こういった解説サイト](https://www.ogis-ri.co.jp/otc/hiroba/technical/openid-connect/chap3.html)を参照してください。

### 事前にやっておくこと

このあたりのことは、今回説明から省きます。

- Auth0のアカウントを作成して、アプリケーションを用意する
- GCPのプロジェクトを作成する
- 配信予定のサイトリソースを準備する

### コードなど

構造の概要はこちらです。必要なものはそんなに多くありません。

```
+ Dockerfile
+ server/
  + envs.conf
  + server.conf
  + on_access.lua
+ public/
  + index.html
  + (他)
```

#### Dockerfile

Dockerfile上だけだとこれだけです。

```Dockerfile:Dockerfile
FROM openresty/openresty:1.19.9.1-4-alpine

RUN apk add --no-cache lua-resty-openidc
COPY ./server /app/server/
COPY ./public /app/public/
RUN ln -s /app/server/server.conf /etc/nginx/conf.d/server.conf \
    &&  cat /app/server/envs.conf >> /usr/local/openresty/nginx/conf/nginx.conf
```

「OpenRestyにOpenID Connect Relaying Partyとしての振る舞いをさせる」というそのものズバリな[lua-resty-openidc](https://github.com/zmartzone/lua-resty-openidc)が公開されています。
そのため、Docker Hub上にあるOpenRestyのイメージと組み合わせるだけで認証可能になります。
さらに、Alpine Linuxのパッケージレジストリに登録されているので、びっくりするほど簡単に組み込むことが出来ました。

`COPY`を使ってイメージ内に持ち込んでいる物の内訳は、それぞれこうなっています。

- `server`: OpenRestyの設定だったりLuaスクリプトなど
- `public`: サイトホスティング用のリソース（画像だったりHTMLだったり）

#### サーバー設定ファイルなど

```nginx:server/server.conf
lua_package_path '~/lua/?.lua;/usr/share/lua/common/?.lua';
resolver 8.8.8.8;

lua_ssl_trusted_certificate /etc/ssl/certs/ca-certificates.crt;
lua_ssl_verify_depth 5;

# cache for discovery metadata documents
lua_shared_dict discovery 1m;
# cache for JWKs
lua_shared_dict jwks 1m;

server {
    listen 8080;

    location / {
        # アクセス時にLuaスクリプトを実行すうｒ
        access_by_lua_file /app/server/on_access.lua;
        root /app/public;
        try_files $uri $uri/ /index.html;
    }
}
```

```nginx:server/envs.conf
env APP_OPENID_DISCOVERY;
env APP_OPENID_CLIENT_ID;
env APP_OPENID_CLIENT_SECRET;
env APP_OPENID_EMAIL_DOMAINS;
```

`server.conf`は、基本的に[lua-resty-openidc](https://github.com/zmartzone/lua-resty-openidc)のREADMEにあるサンプルをベースとして、Luaスクリプトをファイルに切り離した形式となっています。
ただし、Aplineをベースにした今回の構成の場合、`lua-resty-openidc`をLua側で呼び出すにはインストール先を見つけて`lua_package_path`で参照可能にする必要があります。

なお、`envs.conf`は認証用の秘密情報類を環境変数経由で渡せるようにするためのものです。Nginxの`main`コンテキストで定義しないといけないため、サーバー系の設定からは切り離しています。
これらは、Luaスクリプト側の`os.getenv`のために必要となっています。

```lua:server/on_access.lua
local ngx = require("ngx")
local openidc = require("resty.openidc")

-- 文字列を指定した区切り文字で分割（いわゆるsplit）
local function textSplit(text, delimiter)
  local rslt = {}                                  
  for word in string.gmatch(text, '([^'..delimiter..']+)') do
     table.insert(rslt, word)
  end
  return rslt
end

-- メールアドレスと、受付ドメインリストを引数にアクセス許可を与えて良いかを判定する
local function matchDomain(email, domains)
  local domains_ = domains .. ","
  local emailInfo = textSplit(email, "@")
  return string.find(domains_, emailInfo[2] .. ",") ~= nil
end

-- -----------------
-- Main
-- -----------------
-- OpenID Connect の連携先情報
local opts = {
  redirect_uri_path = "/__/callback",
  discovery = os.getenv("APP_OPENID_DISCOVERY"),
  client_id = os.getenv("APP_OPENID_CLIENT_ID"),
  client_secret = os.getenv("APP_OPENID_CLIENT_SECRET"),
}

-- コア: セッション情報の検証、未認証ならリダイレクト...などをやってると思う
local res, err = openidc.authenticate(opts, nil, "deny")

-- 連携周りで処理失敗していたらサーバーエラー扱い
if err then
  ngx.status = 500
  ngx.say(err)
  ngx.exit(ngx.HTTP_INTERNAL_SERVER_ERROR)
end

-- この時点で認証自体は行われているので、メールアドレスをベースにアクセス許可判定する
-- 該当ドメインでない場合は、Forbbidenを返す
if not matchDomain(res.user.email, os.getenv("APP_OPENID_EMAIL_DOMAINS")) then                                                                        
  ngx.exit(ngx.HTTP_FORBIDDEN)
end

-- 問題なければレスポンス
ngx.req.set_header("X-USER", res.id_token.sub)
```

最後がLuaスクリプトです。`server.conf`側の設定の`access_lua_by_file`で指定している通り、アクセスのたびにこのスクリプトが実行されることになります。

中身は大きく3ブロックに分けることができ、それぞれこの様になっています。

- 補助用の関数定義
- 認証関連処理（`openidc.authenticate`や、そのエラーハンドリング）
- 認可関連処理（認証以降の判定など）

`opts`内で色々と環境変数を使っているのは、このあたりの情報を実行時まで保持させないためです。
（その弊害として、前述のように`envs.conf`が必要になってしまっています）

掲載しているコードの「認可関連」の部分について、今回の実装例だと**「認証ユーザーのメールアドレスのホスト部が、あらかじめ環境変数で渡されているホスト名一覧に含まれているか」**という判定をしています。
この実装を用いて、「同じ認証プロバイダーなので認証自体は通るけど、ホスト部分が違うからダメ」といった振る舞いをさせています。

#### ちょっとした補足とか

これらの設定ファイル群とサイトリソースを組み合わせて「Dockerイメージ作成→Cloud Runでデプロイ」とすることで、「Auth0ベースで認証可能+追加の限定条件を満たす」といった事が可能になってきます。
URLはサービスとしてデプロイが完了した時点で確定するので、Auth0の管理画面でコールバックURLには確定したドメインを元に`/__/callback`を付与したURLを設定するようにしましょう。
（例：`https://example.com/__/callback`）

### 動きの例

このような挙動をします。ページアクセス時に即時でAuth0の画面に飛ばされる様子が分かります。

![pages-private.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/6805/7a4d2f13-7258-0c46-00f6-0bfdc3578bc3.gif)

今回は社内のメールアドレスに限定した設定をしているのです。
そのため、普通のGmailだとAuth0の認証自体は通るものの、403 Forbiddenとなり閲覧できません。

![pages-gmail.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/6805/9c9a52ca-9551-3c21-59a7-810cceb79f8e.gif)


## FAQ的なもの

ここからは、実装検証をしていた際にしていた試行錯誤や取捨選択を、1問1答スタイルで書き連ねていきます。

### Cloud RunだとGCPなので、OAuth2クライアントIDを使えばいいのでは？

ディスカバリーの設定を含めて、Google APIを使うこと自体は問題なく出来きます。
しかし、「この基盤をベースに複数の限定共有サイトを運用する」といったことを想像した際に、以下の点が課題になりました。

- コールバックURLにワイルドカードの指定が出来ない
- クライアントIDの操作にWeb画面が必要になる

ワイルドカードが指定できないため、仮にサイトが増えていく際に「単一クライアントIDに対してコールバックURLを追加する」か「クライアントIDを都度増やしていく」という手段が必要になってきます。

もちろん運用上「依頼があるたびに手作業でどうにかする」ことは理論上可能です。
しかし、目指したい世界は「Webコンソール経由でサイト情報を追加したら、もろもろ自動で動く環境」なので、なかなかこの手段に踏み切れません。

幸い、Auth0では**コールバックURLにサブドメイン単位でワイルドカードを設定できる**ので、この辺を気楽に解決できます。楽できるのは良いことですね。

### Cloud RunのFQDNでワイルドカード使おうとすると範囲が広すぎませんか？

Cloud Runのサービスは `https://SERVICE-XXXXX.a.run.app` という形式のURLを払い出します。
ドメインの区切りとしては`SERVICE-XXXXX`.`a`.`run`.`app`となるため、Auth0上でワイルドカードを使おうとすると、`*.a.run.app`となります。
確かに、これだとワイルドカードの適用範囲が「存在する全サービス」となってしまうため、この運用はよろしくありません。

幸いなことに、Cloud Runには「ドメインマッピング(プレビュー版)」という、所持しているドメインとCloud Runのサービスを紐付ける機能があります。
HTTPS通信のための証明書もきちんと用意してくれるので、こちらでマッピングをした上で、カスタムドメインだけをコールバックURLに登録すると良いでしょう。

### セッション管理どうしてるの？

`lua-resty-openidc`はセッションストレージに（OpenRestyの？）共有メモリを選択できるので、それを利用しています。
Cloud Runであることを考えると、コンテナの増減などで唐突な再認証が発生する気もしますが、身内向けのサービスと考えて眼をつぶるようにしましょう。

もちろん、Redisなどの利用も可能ではあるので、必要に応じて適当なセッションストレージを構築して下さい。

### 一度403が返るようなアカウントで連携したら大変じゃない？

大変です。

プロトタイプ時点では、一度Auth0の連携まで進んでしまうとAuth0基盤上でのログアウトが必須になります。
さらにAuth0基盤上でログアウトすると、Googleの認証もろともログアウトするので、各所で面倒な目にあいます。

何かしらの良い手法はあるでしょうが、想定する利用シーンを加味した最終手段として「サイト外での案内に留める」が通用するかなとは考えています。……通用しますよね？

## まとめと、未来の夢想

ここまでの流れで、「ビルド済み静的サイトを限定共有で公開する」という一例の実装を紹介しました。
最初は「なんか色々と込み入った実装とか必要かな？」とという想像をしつつ手を付けたのですが、きちんと情報を探すと案外きれいな形に収まったかなと思います。

とはいえ、これだけだと色々足りてないものはあって、これだけだと「エンジニアが趣味で〇〇してみた」の最初の一歩程度にしかなっていません。

- ビルドした生成物をどうやってサービスとしてデプロイするか
- どうやってデプロイするための器を用意するか

上記のような内容を綺麗にクリア出来て初めてそれっぽいものになるし、他の人が抵抗なく使えるようになって初めてベータと言えます。
このあたりのことをふわっと夢想しつつ、年末年始の気晴らし用ストックに積む宣言をして、記事の結びとします。

[^1]: 仮にリクエスト数や転送量に応じた料金体系でも、小規模組織なら各種PaaSの有料プランより安く収まる。（という仮定）
[^2]: 「動的サイトだったら、最初からそっちで実装したほうが早い」のと「あくまでビルド済みHTMLを公開するのが目的」のため。
[^3]: 例えば、`https://*.example.com/__/callback`と設定しておくと`https://abc.example.com/__/callback`・`https://def.example.com/__/callback`のどちらでも、同一のクライアントIDで処理が可能になります。