ポップアップウィンドウを使用してログイン基盤を作るときにハマるJavaScriptのポイント

## TL;DR

  * ポップアップウィンドウから`postMessage`を使うことで親ウィンドウと通信ができる
  * IEではポップアップウィンドウから`postMessage`を使うことができないのでCORSを使う必要がある
  * iOSのアプリ内ブラウザはポップアップウィンドウ自体をうまく扱えないのでただの遷移以上のことはできない
  * ポップアップウィンドウは真面目に色んなプラットフォームで扱えるようにしようと考えると面倒なことになる

## 挨拶

この記事は[ピクシブ株式会社 Advent Calendar 2017 - Qiita](https://qiita.com/advent-calendar/2017/pixiv)の10日目の記事です。

ピクシブ株式会社で普段はpixivの開発をしたり、広告周りを見たり、インフラっぽい部分を見たりしています。

今回は久しぶりにJavaScriptの話をします。

## 前提

この記事は去年 `https://accounts.pixiv.net/` を開発した際に得られた知見を元にして書きます。

  * ポップアップウィンドウを使って共通ログインページを開いて、そこでログインをしたら元のページもログインされるというシステムを作りたい
  * 共通ログインを提供するアプリケーションはGETクエリで`return_to=URL`を渡すと、ログイン後にURLにリダイレクトするような仕様になっている
    * オープンリダイレクターにはなっていない
    * 以下ログインを提供するURLを`https://example.com/login`とする
  * 共通ログインページでログイン済みならば、各サービスはOAuthなどを使ってログインできる仕組みが存在している
  * ログインの仕組みなので古いブラウザ（IE9含む）でも極力動かしたい
  * スマートフォン・PC共通の仕組みにしたい

また以下みたいなことをすると新しい記法で書けて、minifyとかもできるので便利です。ちょっと古い記事なのでそこはよしなにお願いします。

[webpack(v1)とbabelでES6コードをさくっと書く - getalog](http://geta6.hatenablog.com/entry/2016/04/05/165201)


## ポップアップウィンドウについて

`window.open`関数を叩けば終わり、と行きたいところですが、色々と注意点があります。

### onclickイベント起因で呼び出す必要がある

そうでないとブラウザのポップアップブロックにブロックされます。

### 引数について

``` js
var window = window.open(url, windowName, [windowFeatures]);
```

と3つ引数があります。第2引数でポップアップウィンドウに名前を付けることができます。
これはブラウザ上でユニークであるべきなのでサービスの名称などを含んでおくのがおすすめです。

適切な名前を渡すことで`window.open`関数を実行するボタンを連打してもポップアップウィンドウが無限に生成されることはなくなります。
別のURLで`window.open`を呼び出した場合でも、同じポップアップウィンドウでURLを開き直すという挙動になります。

第3引数はオプションですが、重要な引数です。ここでどういうポップアップウィンドウを開きたいか、サイズや位置をどうするかなどを指定できます。

以下のような関数を用意して第3引数に渡せば、ポップアップウィンドウをスクリーンのど真ん中に出すことができます。

``` js
const windowFeatures = () => {
  const popupSizeWidth = 400;
  const popupSizeHeight = window.screen.height >= 800 ? 800 : window.screen.height - 100;
  const posTop = (window.screen.height - popupSizeHeight) / 2;
  const posLeft = (window.screen.width - popupSizeWidth) / 2;

  return `toolbar=0,status=0,top=${posTop},left=${posLeft},width=${popupSizeWidth},height=${popupSizeHeight},modal=yes,alwaysRaised=yes`;
};
```

ただしこれには注意点があります。スクリーンのど真ん中に出せることが保証できるのはサブディスプレイが付いていないユーザーだけという点です。

サブディスプレイを使用している場合は`window.screen`で返ってくるスクリーンのサイズがユーザーの使用しているどのディスプレイのサイズなのか分かりません。
ブラウザが最初に起動したときに表示されたディスプレイのサイズが返ってきている気がしていますが、全ブラウザで調べたわけではありません。

またポップアップウィンドウをどのディスプレイで開くかを指定する方法はありません。
なのでポップアップウィンドウをディスプレイのど真ん中に表示したい、という場合はサブディスプレイがあるユーザーの場合はちゃんと出るかは運次第になります。

これについては以前検証したときはGoogleのログインもサブディスプレイがある環境では挙動がおかしかったので解決策はなさそうです。もし知っている方がいれば教えてください。
ちなみにその場合でもポップアップウィンドウが出る位置が真ん中ではなくなるだけで、ポップアップウィンドウが開けないというような致命的な問題にはなりません。とりあえず気にしないという方向がいいと思います。

細かいオプションについてはMDNを参照してください。

[Window.open() - Web APIs | MDN](https://developer.mozilla.org/en-US/docs/Web/API/Window/open#Window_features)

またスマートフォンは新しいタブとして開きます。サイズや位置などの指定は無視されます。

また`window.open`の返り値にポップアップウィンドウの参照が入っているので、変数に入れておくことである程度操作ができます。
例えば以下のような実装にしようと考えたとします。

  * クリックしてポップアップウィンドウを生成する
  * その後忘れて他のことをする
  * 思い出してもう一度クリックする
  * 生成されたポップアップウィンドウを使い回しつつ、ポップアップウィンドウを前面に出す

これは以下のコードで実現できます。

``` js
const win = window.open('URL', 'waiwaiWindow', windowFeatures());
// 一度アクティブでなくなったウィンドウにフォーカスを当てたい
win.focus();
```

ポップアップウィンドウの機能についてはまだまだ続きますが、それはこの後順次紹介していきます。


### postMessage

ポップアップウィンドウやiframeは`postMessage`を使うことで呼び出した側のサイトとある程度通信することが可能です。
pixivの一部の広告枠や、このページにも貼られているはてなブックマークボタンなどで使われている技術です。

使い方を簡単に紹介します。

ポップアップウィンドウの中のサイトは以下のようにします。

``` html
<html>
  <body>
    <button id="clicker">押してみて</button>
    <script>
      document.getElementById('clicker').addEventListener('click', () => {
        if (window.opener) {
          window.opener.postMessage('waiwai', '*');
        }
      }, false);
    </script>
  </body>
</html>
```

ポップアップウィンドウの場合は`window.opener`に親ウィンドウの参照が入っているので、そこに対して`postMessage`を行います。
iframeの場合は`window.parent`に対して同様のことをすれば大丈夫です。

親サイトは以下のようにします。

``` js
window.addEventListener('message', (event) => {
  console.log(event.data); // waiwai
}, false);
```

そうしてポップアップウィンドウの中のボタンをクリックしてみると親サイトのconsoleに`waiwai`と表示されます。
このように文字列のみという制約はありますが、ポップアップウィンドウやiframeの中でユーザーが何らかのアクションを起こしたことを親サイトが知ることができます。

実際には`event.origin`を見て想定したドメインから送られてきたmessageなのか確認するべきです。細かい使い方についてはMDNを参照してください。

[Window.postMessage() - Web APIs | MDN](https://developer.mozilla.org/en-US/docs/Web/API/Window/postMessage)


#### window.openerについて

これでpostMessageが最高の技術であることは伝わったと思うのですが、先程紹介した`window.opener`には致命的な弱点があります。

なんと__IEでは`window.opener`に親ウィンドウの参照は入りません__（バージョンは関係なし。Edgeは大丈夫）。

iframeの`window.parent`にはこの問題は無いので積極的に使って問題ありません。

さすがはIE。期待を裏切りません。新しめのIEについてはCORSである程度救済できるので後述します。


#### postMessageを用いたログイン基盤の実装

以下のようになります。

  1. ログインさせたいサービス上のログインボタンをクリックしたときに、共通ログインページをポップアップウィンドウで開く
  2. 共通ログインページのURLにreturn_toをつけてログイン後に特定のページにリダイレクトするようにしておく
  3. その特定のページで`postMessage`を使ってユーザーがログインしたことをログインさせたいサービスに通知
  4. ポップアップウィンドウを閉じて、ログインさせたいサービスはログインに必要な処理を行う

この仕組みでスマートフォンを含めて、ほとんどのブラウザで問題無く動作します。めでたしめでたし。

……と行きたいところでしたが、先程も説明したようにIEが動きません。また後ほど説明しますが、実はこの仕組みは__iOSのアプリ内ブラウザで動作しません__。

これだけで済めばポップアップウィンドウをおすすめしていきたいところなんですが、実際はそううまくはいきません。なのでもう少し解説は続きます。


## CORSについて

みんな大好きCORS(Cross-Origin Resource Sharing)の話をします。詳しくは例によってMDNを参照してください。

[Cross-Origin Resource Sharing (CORS) - HTTP | MDN](https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS)

また実際に実装しようとすると例によってIEでハマります。詳しくは以下のエントリーを参照してください。

[CORSでハマったことまとめ - pixiv inside](http://inside.pixiv.net/entry/2014/12/16/181804)

CORSでログインしているかどうかをbooleanで返すJSON APIを提供すれば、そのユーザーがログインをしているかどうかを以下のようなコードで容易に取得できます。

``` js
const req = new XMLHttpRequest();
req.open('GET', 'https://example.com/cors/status', true);
req.withCredentials = true;
req.onload = () => {
  const res = JSON.parse(req.responseText);
  if (res.is_login) {
    // ログインしているのでログイン処理をここで実行
  }
};
req.send(null);
```

ポイントはCookieを送る必要があるので`withCredentials`をtrueにする必要があるところです。

この仕組みを使えば、例えば既にログインしている人は自動でログインさせるという自動ログインの仕組みを作ることもできます。
ただしその場合は後述するSafariのIntelligent Tracking Preventionの問題を踏む可能性が高くなるかもしれません。


### IEの話

先程紹介したエントリーにも書かれていますが、IEのJavaScript周りについてはここでも解説します。

CORSに対応している`XMLHttpRequest`は`XMLHttpRequest Level 2`と呼ばれる仕様に対応している必要があります。

最近のブラウザは対応していますが、IEではIE10以上からの対応です。
IE8とIE9は`XDomainRequest`という独自の実装がありますが、先程紹介した`withCredentials`相当の機能は無いのでCookieを送る必要があるケースでは使用できません。

なのでIE9以下をちゃんと対応することはできないですが、以下のようなコードを書くことでエラーになることは防ぐことができます。

``` js
if (typeof req.withCredentials === 'undefined') {
  // 強制的にログイン処理をする
  return;
}
```

`win.closed`を確認すればポップアップウィンドウが閉じられたかどうかが分かります。
そこでポップアップウィンドウの中で開く特定のページ上では`postMessage`を送るだけではなく、処理が終わったら自分自身を閉じるようにしておきます。

``` js
window.setTimeout(function() {
  window.self.close();
}, 0);
```

そしてログインさせたいサービス上からは以下のような実装をすれば、IEでもポップアップウィンドウでログインした後にログイン処理を実行することができます。

``` js
const win = window.open('https://example.com/login', 'waiwaiWindow', windowFeatures());

const timerId = setInterval(() => {
  if (win && win.closed) {
    clearInterval(timerId);

    // IE9, IE10, IE11（window.openerが呼べないブラウザ）への対応
    // ログインしているかどうかの確認する
    const req = new XMLHttpRequest();

    // for IE9
    // どうしようもないので問答無用でログインさせる
    if (typeof req.withCredentials === 'undefined') {
      // ログイン処理
      return;
    }

    req.open('GET', 'https://example.com/cors/status', true);
    req.withCredentials = true;
    req.onload = () => {
      const res = JSON.parse(req.responseText);
      if (res.is_login) {
        // ログイン処理
      }
    };
    req.send(null);
  }
}, 200);
```

実際にはこの処理は`window.opener`に対応している他のブラウザでは実行されないようにしておいた方がよいです。
とりあえずこれでIEでもポップアップウィンドウを使用してログインさせることができます。


### SafariのIntelligent Tracking Prevention

CORS周りにまつわる問題として、Safariのユーザーの行動をトラッキングするCookieの扱いが特殊なので紹介します。以下のURLに詳しく書かれています。

[Safari 11 Intelligent Tracking Preventionについて - Cybozu Inside Out | サイボウズエンジニアのブログ](http://blog.cybozu.io/entry/2017/10/13/080000)

動きをざっくりと書くと『最後のユーザーインタラクションから24時間以内しかサードパーティーのCookieを使用でき』ません。
共通のログインサービスにログイン済みであったとしても、ログインさせたい別サービスからCORSのAPIを叩いてもログインしてないように見えます。
そのためCORSのAPIでログイン済みだったら自動ログインする機能を作った場合、Safariだと正しく動作しない可能性があります。

この挙動は『機械学習による分類で、クロスサイトトラッキングを行っている疑いがあると判定されたドメイン』のみが対象です。
自動ログイン機能を実装した場合、CORSのAPIに定期的にリクエストを送ることになるのでトラッキングを行っていると判定されやすくなるかもしれません。


## 実際に実装しようとしてハマる問題

以上の内容でポップアップウィンドウを使ったログイン基盤を作れます。

……と言いたいところでしたが、実際には予期しないトラブルがつきものです。ここでは起こった問題と最終的にどういう実装にするべきなのか書きます。


### IEで謎のトラブル

IEではポップアップウィンドウが動かない環境があるようです。残念ながら私は再現する環境を見つけることはできませでしたが、確実に一定数存在するようです。

そもそも発生する条件なども不明なので対策方法は分かりません。
ポップアップウィンドウによるログインではなく、共通のログインページに`return_to`をつけたURLにリンクとして遷移させて、ログイン後に元のサービスにリダイレクトするようにするしかないと思います。


### アドブロックにブロックされる

ブラウザの拡張機能でアドブロックを入れている場合、ポップアップウィンドウ自体が開かないケースもあるようです。これについてはバグるサービスは少なくないので、どこまで気にするかという問題だと思います。

### iOSのアプリ内ブラウザ

スマートフォンのブラウザでポップアップウィンドウを使用すると新しいタブで開きますが、親ウィンドウと子ウィンドウの関係性は保たれます。
なので通常は以上の方法で動作するのですが、iOSのアプリ内ブラウザだけは期待通りの動きをしませんでした。

挙動としては単なる画面遷移となり、元のページが破棄されていて元に戻れなくなります。
先程IEのためにウィンドウを閉じる実装にしましたが、ポップアップウィンドウではないので閉じる処理は無視されます。

これについての解決策としては以下の実装です。

  1. ログインさせたいサービスのURLをreturn_toに含んでおく
  2. ポップアップウィンドウでログインし終わった後に遷移するページ上で一定時間経過後にウィンドウが閉じられていなかった場合、遷移できないパターンと認識して自身のURLに含まれているURLにリダイレクト

気を付けないと行けないのはリダイレクトする処理は適当にJSでリダイレクトするだけだとオープンリダイレクターになる点です。
そこで例えば以下のような実装にするといいと思います。

ログインさせたいサービス上で以下の実装をします。

``` js
// hashにlocation.hrefを載せておく
const returnToUrl = encodeURIComponent(`https://example.com/login_after#${location.href}`);
const win = window.open(`https://example.com/login?return_to=${returnToUrl}` + '', 'waiwaiWindow', windowFeatures());
```

これでポップアップウィンドウ上でログインすると`https://example.com/login_after#${location.href}`に遷移します。このページ上で以下の実装をします。

``` js
window.setTimeout(() => {
  const hash = location.hash;
  // hashがある程度の長さでhttps?://であることを保証する
  // ログイン済みのはずなので適切にリダイレクトしてくれる
  if (hash.length > 10 && /^#https?:\/\//.test(hash)) {
    location.href = '/login?return_to=' + encodeURIComponent(hash.slice(1));
  }
}, 1000);
```

こうするとこのページが表示されて1秒後もまだ表示されていたら共通ログインページのURLにログインさせたいサービスのURLを`return_to`につけてリダイレクトします。
このユーザーは既にログインしているはずなので、単なるリダイレクターとして機能してログインさせたいサービスに戻ることができます。
今回`location.href`にしましたが、これをログインさせたいサービスのログイン時の処理を実行するURLにすればログインさせることができます。

ちなみにiOSのアプリ内ブラウザの検証はtwitterで自分自身にDMを送り、twitterのアプリ上で検証していました。参考になれば幸いです。


### 最終的な実装

先程紹介したiOSのアプリ内ブラウザのことを考えると、ログイン時の処理はログインさせたいサービスのログインURLを叩かせること以外できません。
ログイン時にリダイレクト以外、例えばGoogle Analyticsにリクエストを送りたいなどの要件があるかもしれません。
最初に紹介した`postMessage`やCORSを使う方法ではJS側で処理を挟み込むことは可能ですが、ポップアップウィンドウが扱えない一部のIEやiOSのアプリ内ブラウザはどこかのURLにリダイレクトさせる以上のことはできません。

なのでポップアップウィンドウでログインさせた後に、ログインで叩く必要があるURLに遷移するだけという実装がいいと思います。

この辺りはどこまでを許容するのかという話でもあるので、よく把握した上で最適なものを選ぶ必要があります。

## 個人的な感想

  * ポップアップウィンドウでログインさせる仕組みは`postMessage`で作るといい感じにできる
  * ただしIEやiOSのアプリ内ブラウザのことを真面目に考えると非常に制約のある仕組みになる
  * 普通にリンクで遷移してreturn_toで元に戻るだけでも十分かもしれない

## 最後に

ピクシブ株式会社ではJavaScriptを使った開発が好きなエンジニアを募集しています。

明日は @fsubal が`var_export()`を使ったおもしろい話をしてくれます。