nginxとLassoを使ってサイトをOpenID Connect(Auth0)で保護する

(はてなダイアリー版: http://d.hatena.ne.jp/mjt/20180923/p1 )

**★ 注:** Lasso自体はあんまりプロダクションに向いていない印象を受けた。全く同じことはoauth2_proxyでも可能で、oauth2_proxyのforkもいくつかあるので、そちらを検討する方が良い気がしている。

基本的にはサイト側でOpenID Connectのサポートをするのが望ましいが、nginxにはリクエストヘッダだけを一旦別のHTTPサーバに送信し、その結果によってアクセス可否を決める `auth_request` モジュールがあるため、HTTP認証サーバと適当なブラウザベースの認証機構を用意すれば任意のサイトを任意の認証フレームワークで保護することができる。

![neta180923l1a.png](https://qiita-image-store.s3.amazonaws.com/0/67446/40785827-9c0c-46a3-b608-e7fd4ce84798.png)

従来は oauth2_proxy というbit.ly製の認証<b>プロクシ</b>がこの手のユースケースには(おもにk8s界隈で e.g. https://qiita.com/nirasan/items/7d5d9321fcf3e5c6c82a )よく使われていたが、oauth2_proxyはbit.ly側でのメンテナ不在により今月末でのプロジェクト終了が宣言( https://github.com/bitly/oauth2_proxy/issues/628#issuecomment-417121636 )されている。

認証プロクシは、バックエンドHTTPサーバへのリクエストも行う。それに対して、 `auth_request` による認証の移譲ではバックエンドサーバへのリクエストはnginxが行うことになる。このため、`auth_request`で使う認証サーバはnginxの十分近くに配置する必要がある -- 認証プロキシに比べて、TCPなりなんなりの接続コストがそのままリクエスト遅延に上乗せされる。もっとも、認証プロキシでは事実上フロントエンド側のリバースプロキシによって2重のコンテンツバッファリングが発生してしまうので、どちらも一長一短な気はする。

* https://github.com/LassoProject/Lasso
 * LassoのGitHubページ
* http://nginx.org/en/docs/http/ngx_http_auth_request_module.html
 * nginxの`auth_request`モジュールのドキュメント
* https://developer.okta.com/blog/2018/08/28/nginx-auth-request
 * Oktaのブログ: Use nginx to Add Authentication to Any Application 

今回の方法はメジャーなSSOベンダである Okta のブログに有るが、OktaでなくAuth0( https://auth0.com/ )を使ってみた。どちらも開発者向けには無料アカウントが有り、1000人までのユーザベースなら無料となっている。

今回は、`stripe.local/auth/*` をLassoのための認証に使用し、`stripe.local/testing/*` というWebリソースの保護を考える。

# nginxの設定
nginxの設定はほぼOktaのブログに掲載されている通りだが、リライトを使ってrootを移動している。

```
http {
    server {
        listen 443 ssl;
        server_name stripe.local;
        ssl_protocols TLSv1.2;

        ssl_certificate f:/serv/keys/out.cer;
        ssl_certificate_key f:/serv/keys/key.pem;

        location /testing/ { ★ ここが保護したいリソース
            auth_request /lasso-validate;
            error_page 401 = @error401;
            root f:/serv;
        }

        location @error401 { ★ Lassoから401エラーが帰ってきた場合のリダイレクト先
            return 302 https://stripe.local/auth/login?url=https://$http_host$request_uri&lasso-failcount=$auth_resp_failcount&X-Lasso-Token=$auth_resp_jwt&error=$auth_resp_err;
        }

        location /auth { ★ ブラウザからLassoにアクセスできるようにする
            rewrite /auth/(.*) /$1 break;
            proxy_set_header Host login.stripe.local;
            proxy_set_header X-Forwarded-Proto https;
            proxy_pass http://127.0.0.1:9090;
        }

        location /lasso-validate { ★ 認証の移譲先
            proxy_pass http://127.0.0.1:9090/validate;
            proxy_pass_request_body off;

            proxy_set_header Content-Length "";
            proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
            proxy_set_header X-Forwarded-Proto https;
            proxy_set_header X-Forwarded-Port 443;

            auth_request_set $auth_resp_jwt $upstream_http_x_lasso_jwt; ★ リダイレクト先指定で参照できる変数のセット
            auth_request_set $auth_resp_err $upstream_http_x_lasso_err;
            auth_request_set $auth_resp_failcount $upstream_http_x_lasso_failcount;
        }
```

nginx は **常に** ユーザのリクエストヘッダをLassoに転送し、LassoはCookieの内容をチェックして認証済かどうかを確認する。認証済のCookieを持っていなかったら、Lassoは`401`を返してnginxに上で宣言したような `@error401` を実行させ、結果的にユーザは`302`でLassoの `/login` に転送される。正当なCookieを持っていたら、Lassoは`200`を返し、nginxはそのままコンテンツの提供を継続する。

Lassoは保護されたリソースへのリクエストを全て検証することになるので、パフォーマンスに対する要求はそれなりに高いことになる。

ここでは認証したユーザ名の処理は特に実装していない。つまり **OpenID Connect認証に成功したユーザは無条件でリソースへのアクセスが許可される** 。 <a href="https://developer.okta.com/blog/2018/08/28/nginx-auth-request#bonus-who-logged-in">Oktaのブログのセクション Bonus: Who logged in?</a> やLassoの`README.md`に、HTTPヘッダを経由してユーザ名を渡す方法が有る。

# Auth0の設定
Auth0では適当にテナントを作成した上で Regular Web Application を作成し、Allowed callback urlsをLassoの `/auth` を指すように設定する。今回の場合は、 `https://stripe.local/auth/auth` になる(本来は `/auth` だけだがrootを移動しているため)。

![neta180923l2.png](https://qiita-image-store.s3.amazonaws.com/0/67446/cdb4af49-05f0-e3bf-7e5a-a2fd994b1d10.png)

LassoにはOpenID Connectの自動設定(discovery)機能は無いので、LassoのYAMLには諸々の設定を手動で実施する必要がある。これらのデータは *Advanced settings >> Endpoints* 以下にある。

![neta180923l3.png](https://qiita-image-store.s3.amazonaws.com/0/67446/5446e1b7-51b2-fdcf-d730-537392bc51bb.png)

実際のユーザについては、Auth0の無料アカウントでは2種類までのソーシャルログインを有効化することができる。ソーシャルログインを使うと、ユーザの持っているGoogleとかTwitterのアカウント認証で認証を代替できる。ただし、複数のソーシャルアカウントを1つのAuth0上のユーザに結びつける機能は無料アカウントでは提供されない。(non-profitなFOSSについてはJIRA/Confluenceのように有料版をコストなしで提供している。)

# Lassoの設定
Lassoの設定は、Lassoのexecutableがあるディレクトリ上の config/config.yml で行う。

```yaml
lasso:
    logLevel: debug # ★ 機密情報もガンガン出るので注意
    listen: 127.0.0.1
    port: 9090
    # !!!! FIXME !!!! allowAllUsers for now.
    # Lassoは元々Googleアカウント等への統合を想定しているため、ユーザ名中のドメインを追加で検証できるようになっている
    allowAllUsers: true
    publicAccess: false

    jwt:
        issuer: Lasso
        maxAge: 240 # 240分 = 4時間は再認証しない
        secret: lZlMPLfBjcIQCjbxjEoKep5dWn6xDjyW # これは乱数で良い
        compress: true

    cookie:
        name: Lasso-cookie
        secure: true
        domain: stripe.local
        httpOnly: true
    session:
        name: lasso-session

    headers:
        jwt: X-Lasso-Token
        querystring: access_token
        redirect: X-Lasso-Requested-URI

    db:
        file: data/lasso_bolt.db # data/ ディレクトリを事前に用意しておくこと

oauth:
    provider: oidc
    # ★★ 以下のclient_idとclient_secretは本来開示してはいけない。
    client_id: TfMpW26Hah9HI5sQw0WpVoCIhCS2V5qN
    client_secret: Q9eBFIKMRkhSGjjfrSxDITHDNgV2UwnMO4KqKR5jTBCqNbSNQBfRC50fZ5ozX1fc
    auth_url: https://yuni.auth0.com/authorize
    token_url: https://yuni.auth0.com/oauth/token
    user_info_url: https://yuni.auth0.com/userinfo
    scopes:
        - openid
        - email
    callback_url: https://stripe.local/auth/auth # Auth0に設定したCallback urlに一致すること
```

OpenID Connect OPのなかには、`callback_url`としてローカル端末を許可していないことがある。Auth0の場合は問題なく設定できるが、プロバイダ側で許可されていない場合は`/etc/hosts`を書くとか何らかの方法でワークアラウンドが必要になる。

YAMLにはいくつかの機密情報を設定する。`client_id`と`client_secret`は認証を提供するOpenID OP(= Auth0)がRP(= ここで設定したLasso)の真正性を確認するのに必要となる(プロトコル上機密情報なのは`client_secret`の方)。jwtのsecretはLassoが発行するJWTの認証鍵として使われる(これをランダムにしないと、別のLassoインスタンスが同様にJWTを発行できてしまう)。

# この手法はOpenID Connectをユニバーサル認証手段にするか？

というわけで、`auth_request`を使うと任意のブラウザ認証手法をWebサイトに統合できることがわかった。もっとも、これがどの程度役に立つかは何とも言えない。

明確に役に立つのは、本来認証の無いWebサイト -- 例えば内部で作っているダッシュボードとか -- にそのまま認証を付加できるポイントだろう。ただ、他の認証手段(BASIC認証やアプリケーション組込の認証)に比べると:

* **Deployが大変** 。通常の ID/パスワード認証であればBasic認証で良いし、LDAPを認証基盤としていて、かつOktaのようなOpenID Connectプロバイダを採用していないなら(あるいは自由にアプリケーションを追加できる環境でないなら)dex https://github.com/dexidp/dex とかKeyclockのようなOpenID Connect実装を別途用意しておく必要がある。
* **アクセス権の扱いが微妙** 。グループ等のclaimを伝播させる良い方法が無い。有料版のnginxにはJWTのパース処理 http://nginx.org/en/docs/http/ngx_http_auth_jwt_module.html が存在するのでnginx内での処理も原理的には可能だが。。
* **ユーザサービスを提供する余地が無い** 。そもそもアプリケーション側で明示的に対応しないと "ログアウト" みたいなリンクを置く場所が無い。
* **複数サービスの統合が難しそう** 。今回のセットアップでは、Lassoは `/` に認証クッキーを設定する。このため複数の異なる認証機構を単一ドメインでサービスするためには追加の考察が必要になる。

個人的には、HTTP経由のWindows統合認証が上手く動いた試しが無いので、ID/パスワードを前提とした実装よりは、OpenID Connectベースの認証に寄せた方がSSOや2FAの実装の上では有利な気はしている。...でも自作アプリの割合が多い現状を考えるとアプリ側に認証を用意した方がUI的には便利というか何というか。。