Oauth2のRedirect URLがdynamicな場合にproxyでなんとかする

### TL;DR
とある目的でZendeskのAPIを叩くアプリにOauth2を使おうとしたら、callback先のアプリのURLがdynamicで詰んだ。
しかたなくcallbackのURLにproxyを挟んでURLを強制的に一意にし、Stateパラメータ使ってdynamic URLの情報をproxyに渡して、proxyでparameterを読んで加工して正しいcallback URLへリダイレクトということをやった。

## callback先のアプリ = Google App Script
アプリとは言ったものの、実は今回Oauth2を使いたかったのはGoogle App Script(GAS)です。※Googleが公開している素晴らしいOauth2用のLibraryがあるので、GASからOauth2で外部サービスと繋ぎたい場合は迷わず使いましょう！

 - [googlesamples/apps-script-oauth2](https://github.com/googlesamples/apps-script-oauth2)

### Zendeskの基本的なOauth2のフロー
- [Using OAuth authentication with your application](https://support.zendesk.com/hc/en-us/articles/203663836-Using-OAuth-authentication-with-your-application)

基本的には上記の内容が全てです。問題はこの赤枠の部分、
![oauth_authorization_flow.png](https://qiita-image-store.s3.amazonaws.com/0/19942/cb92ec36-dc1f-ebfd-df86-a7f7ff539925.png)

例えばGoogle App Scriptのコードが紐付いたSpreadsheetが1つあるとして、その1つだけが対象であればcallback URLは変わらないので問題ないですが、今回のケースではユーザがテンプレートのSpreadsheetを各自Copyして使う想定がされていて、Copyした先のSpreadsheetのcallback URLは変わってしまいます(callback URLがscript IDを含んでいるため)。

### GASのcallback URL

`https://script.google.com/macros/d/{SCRIPT ID}/usercallback`

先に説明したように、callback URLは`{SCRIPT ID}`を含んでいます。

### ZendeskにOAuth clientを登録する
この時、当然事前にcallback URLを登録しておく必要がありますが、未来の`{SCRIPT ID}`を知らないのでこの方法は使えません。

<img width="1227" alt="Screen Shot 2018-02-20 at 17.51.05.png" src="https://qiita-image-store.s3.amazonaws.com/0/19942/3ef2ae1f-9bcd-f5c3-d86d-89978309914d.png">

## proxyを挟んでみる（Fastly as a reverse proxy）
ここで普通に考えると思いつくのは間にproxyを挟んでなんとかしよう、という結論に至りますが、如何せんこれだけのためにproxyをわざわざ用意したくない。面倒も見たくない。じゃあどうするか、とりあえずFastlyをreverse proxy代わりにして、zendeskからcallbackのリクエストを受けた時にそいつを正しいGASのcallback URLにリダイレクトさせてあげればいいのでは、という事になりました。

- [Custom responses that don't hit origin servers](https://docs.fastly.com/guides/vcl/custom-responses-that-dont-hit-origin-servers)

Fastlyには`synthetic`レスポンスというカスタムレスポンスを返す事ができる機能があり、これを使うとoriginレスで任意のレスポンスをclientに返すことができます。
今回の要件だと、clientに正しいcallback URLへの301 リダイレクトを返したいだけなので、まさにピッタリです。

## Oauth2 State parameter に思いの丈を綴る
先の方法を実現する上で唯一残る問題は、どうやって最終的なGASのcallback URLをZendesk, ないしはproxyのFastlyへ伝えるか、という点です。堂々とオススメできる方法ではもちろんありませんが、Oauth2でCSRF攻撃を防ぐためのOPTIONALなパラメータとして定義されている`State`に一時的に任意の値を付け加えて、それをproxy上で読み込み、切り取り、元のstateに修正してリダイレクトしてしまえば原理上は問題ないはず、です。

Stateパラメータについては下記のリンクなどが参考になると思います。

 - [OAuth 2.0のstateとredirect_uriとOpenID ConnectのnonceとID Tokenについて](http://d.hatena.ne.jp/ritou/20121008/1349695124)
 - [The State Parameter](https://auth0.com/docs/protocols/oauth2/oauth-state)
 
一番はじめに紹介したGASのOauth2用Libraryのコードを見ると、Stateパラメータを追加しているのは下記の部分です。
https://github.com/googlesamples/apps-script-oauth2/blob/24/dist/OAuth2.gs#L340-L352

これを、思い切ってこうしちゃいます。

```js
var redirectUri = getRedirectUri(this.scriptId_);
var state = eval('Script' + 'App').newStateToken()
    .withMethod(this.callbackFunctionName_)
    .withArgument('serviceName', this.serviceName_)
    .withTimeout(3600)
    .createToken();
var params = {
  client_id: this.clientId_,
  response_type: 'code',
  redirect_uri: redirectUri,
  // add script_id to 'state'
  state: state + '&' + this.scriptId_
};
params = _.extend(params, this.params_);
```

こうすることで、zendeskに渡される`State`パラメータは、
`state=${generated_original_state_strings}&{gas_script_id}`
となります。これをFastlyのVCLで読み取ってうまいことします。(※正確には送られる際に`urlencode`されるので、`&`は`%26`に置き換わります)

## Fastly のVCLでparameterをいじる
Fastlyで行う処理は至ってシンプルです。originは必要ないので、適当に`127.0.0.1`などをbackendとして登録し、ドメインを設定します(ドメイン持ってなくても[無料TLS](https://docs.fastly.com/ja/guides/securing-communications/setting-up-free-tls)を使えば問題ないです)。次に、`vcl_recv`と`vcl_error`にVCLスニペットを使ってコードを足していきます。

### `vcl_recv`
```c
# Snippet callback
if (req.url ~ "^/usercallback") {
  # state parameter 以外の全てのクエリを含めたリクエストURLをいったん保存
  set req.http.org-url = querystring.filter(req.url, "state");
  # 送られてきたクエリからstate parameterの値だけを抜き出す
  set req.http.oauth2-state = subfield(req.url.qs, "state", "&");
  # 正規表現を使って元のstate値と付け足したstate値を切り出す
  if (req.http.oauth2-state ~ "^(.*)%2526(.*)$") {
    set req.http.x-org-state = re.group.1;
    set req.http.x-gas-url = re.group.2;
  }
  # synthetic レスポンスを返すために vcl_error へジャンプ
  error 900;
}
```

### `vcl_error`
```c
# Snippet callback_error
if (obj.status == 900) {
    set obj.status = 301;
    set obj.response = "Moved Permanently";
    # vcl_recv で切り出した値を使って Location ヘッダを組み立てる
    set obj.http.Location = "https://script.google.com/macros/d/" req.http.x-gas-url req.http.org-url "&state=" req.http.x-org-state;
    synthetic {""};
    return (deliver);
}
```

必要なコードは最低限以上になります。
最後に、zendeskのOauth clientのredirect urlに、Fastlyに設定したドメインを登録します。
`https://<name>.global.ssl.fastly.net/usercallback`

## GASのOauth2 Library側でもredirect urlを書き換える。
最後に忘れてはいけないのが、先に紹介したLibrary側でもZendeskへ送るredirect urlをproxyのドメインに書き換えないといけない点です。そうしないとLibraryは通常のGASにcallback URLをparameterに追加して送るため、redirect urlの不一致で認証が失敗します。

もう一度コードに戻ると、redirect urlを設定しているのは下記の部分なので、無理やりだと例えばここを単にFastlyに設定したURLを返すように書き換えるとよいと思います。
https://github.com/googlesamples/apps-script-oauth2/blob/24/dist/OAuth2.gs#L71-L73

```js
function getRedirectUri(scriptId) {
  return Utilities.formatString('https://script.google.com/macros/d/%s/usercallback', scriptId);
  // return "https://<name>.global.ssl.fastly.net/usercallback" とか。
}
```

もしくは、`getService()`内で
`.setParam('redirect_uri', "https://<name>.global.ssl.fastly.net/usercallback")`
して、さらに`.setTokenPayloadHandler()`を使ってHandler先の関数で

```js
function zendeskTokenHandler(payload) {
  payload.redirect_uri = 'https://<name>.global.ssl.fastly.net/usercallback';
  
  return payload;
}
```
という手順を踏むとか。

以上です。もっと良い方法を知っている方がいたら是非教えてください〜！