Web版「Sign In with Apple」を実装する

今年のWWDCで発表された「Sign In with Apple」のWeb版(JavaScript版)を実装してみたいと思います。

今回、裏側はFirebaseを利用していますが、Authenticationは使っていませんので、一般的な環境でも参考になると思います。(執筆時点でまだFirebaseが未対応なので使いたくても使えないわけですがw)

※2019-09-23 「1.5 ユーザーを一意に特定するID」追記

developer.apple.com

AppleIDでログイン

サンプル

以下のページで実際の動作を確認できます。 miku3.net

  • PC、スマホ(iOS,Android)ともに動作します。
  • 2段階認証(多要素認証)を設定している場合はID/PWでログイン後に聞かれることになります。
  • このサンプルではログイン情報の記録はしていません。

ソースコード

index.html

最初のページです。ログイン用のボタンが表示されます。

<!DOCTYPE html>
<html>
<head>
  <meta charset="UTF-8">
  <title>Test for 「Sign In with Apple」</title>
</head>
<body>

<!-- ボタン -->
<div id="appleid-signin"
  data-color="white"
  data-border="true"
  data-type="sign in">
</div>

<script type="text/javascript" src="https://appleid.cdn-apple.com/appleauth/static/jsapi/appleid/1/en_US/appleid.auth.js"></script>
<script type="text/javascript">
  // 本来はCSRF対策用の文字列を準備してください
  const rand = 'ST' + Math.ceil( Math.random() * 100000 );

  // ログイン処理
  AppleID.auth.init({
        clientId : "net.miku3.id.apple.service",
           scope : "email",
      redirectURI: "https://miku3.net/appleid/callback",
           state : rand
  });
</script>
</body>
</html>

AppleID.auth.init()に渡す引数はそれぞれ以下の通り。

項目 説明
clientId AppleのDeveloperサイトでIdentifier作成時に設定した値をそのまま書きます(後述)
scope 取得したいユーザー情報を記述します。ユーザー名とメールアドレスの指定が可能ですがどちらもWebからだと現状取得できません。
redirectURI ログイン後に戻ってくるURIを記述します。Developerサイトへ事前に登録しておく必要があります(後述)
state CSRF対策用の文字列を指定します(後述)

またボタンの表示で利用する属性の内容は以下の通り。

属性 説明
data-color ボタンの背景色を指定。blackまたは white
data-border 枠線を表示するかをBooleanで指定。
data-type ボタンの種類を指定。sign in, sign up, continue, appleの中から選択

ボタンの大きさはCSSで変更可能です。

.signin-button {
  width: 210px;
  height: 40px;
}

デザイン周りの詳細はAppleのガイドラインを参照してください。

callback

無事にログインした後に表示されるページです。 ログイン時にredirectURIで指定したURLに、POSTメソッドで戻ってきます。今回はFirebaseのCloudFunctions(Node.js)を利用していますが、Appleから送られてくるクエリーの値をJSON風に表示しているだけです。詳しくは「ユーザーを一意に特定するID」の段落で説明しますが、ユーザーIDを取り出すためにjsonwebtokenモジュールを利用しています。

実際に利用する場合はログイン時に渡したstateの値がreq.body.stateにそのまま入っていますので必ずチェックを行ってください。またユーザーIDはdecoded.subで取得することができます。

const functions = require("firebase-functions");

exports.appleidCallBack = functions.https.onRequest( (req, res) => {
  const jwt = require("jsonwebtoken");
  const buff = ( req.method === "POST" )? req.body:req.query;

  // このあたりでCSRF対策用の文字列(req.body.state)のチェック

  // id_tokenをデコード
  const decoded = jwt.decode(buff.id_token);

  res.send(
      "<h1>Wellcome Back</h1>"

    + "<h3>Appleから渡される値</h3>"
    + "<pre style=\"border:1px solid gray;\">"
    +     JSON.stringify(str, null, 4)
    + "</pre>"

    + "<h3>id_tokenをデコード</h3>"
    + "<pre style=\"border:1px solid gray;\">"
    +     JSON.stringify(decoded, null, 4)
    + "</pre>"

    + "<hr>"
    + "ver.0.0.2"
  );
});

Firebase.jsonでrewritesの部分を以下のように書き足しています。これで/appleid/callbackへリクエストが来ると、上記のCloudFunctionsが実行されるようになります。

{
  "hosting": {
    "public": "public",
    "rewrites": [ {
        "source": "/appleid/callback", "function": "appleidCallBack"
      }],
    "ignore": [
      "firebase.json",
      "**/.*",
      "**/node_modules/**"
    ]
  }
}

CloudFunctionsを独自ドメインで利用する方法については以下の記事を参照ください。 blog.katsubemakito.net

CSRF対策

stateで指定する値は、このページを識別するためのコードと、セッションIDなどを組み合わせた物にしておけば良いと思います。

PHPで書くなら以下のような感じ。唐突なPHPw!

// このページのCDを定義
define('PAGE_CD', 'AUTH-APPLE');

// セッション開始
session_start();

/**
 * Stateの値を生成
 */ 
function getState(){
  $page_cd = PAGE_CD;       // ページを識別するためのCD
  $sess_id = session_id();  // 本人を識別するためのID

  return(sha1($page_cd . $sess_id));
}

/**
 * Stateの値をチェック
 */
function checkState(){
  $page_cd = PAGE_CD;;        // ページを識別するためのCD
  $sess_id = session_id();    // 本人を識別するためのID
  $state   = $_POST['state']; // Appleから渡されたstate

  return(
    sha1($page_cd . $sess_id) === $state
  );
}

index.htmlgetState()で生成した値を渡し、redirectURIで指定したURIのページに戻ってきた際にcheckState()でチェックするイメージです。

Appleから渡される値

redirectURIで指定したURIにPOSTメソッド戻ってくるわけですが、このときに以下のような値が渡されます。

クエリー名 説明
state AppleID.auth.init()で渡したstateの値がそのまま入っています
code 5分間有効なシングルサインオン用の値
id_token ユーザーを識別するための文字列。JWT(JSON Web Token)

scopeemailを指定しましたがガン無視されますw 現状だとWeb版はメールアドレスはおろかユーザー名さえも取得できないみたいですね。NDKだとこのあたりうまく行くようなので、どうしてもこれらの情報がほしい場合はiOS用のネイティブアプリを利用することになりそうです。

ユーザーを一意に特定するID

2019-09-23追記

ログインする度にユーザーを特定するID的な文字列はid_tokenの中に含まれていますが、JWT形式で渡されるため、そのままでは利用できません。

JWTの詳しい説明は端折りますが、書式としては2つのドットで全体が3つに区切られてまして、それぞれ次を意味しています。結論から言うとこのペイロード内にあるsubがユーザーIDにあたります。

  1. ヘッダー(署名のアルゴリズム)
  2. ペイロード(実際の値が入っている部分。JSON)
  3. 署名(改ざんの検証に利用)

具体的な処理としては、最初にドット(.)で文字列id_tokenを分割します。ヘッダーとペイロード部分がBase64でエンコードされているので、ペイロードを添付ファイルのようにデコードするとJSON文字列が現れます。このJSONの中の"sub"の部分がユーザー毎に割り振られた不変の値(ID)になるのでこいつを取り出します。

以下は実際のid_tokenを役割ごとに色分けした物です。このうち真ん中の薄い水色のペイロードを取り出します。

eyJraWQiOiJBSURPUEsxIiwiYWxnIjoiUlMyNTYifQ.eyJpc3MiOiJodHRwczovL2FwcGxlaWQuYXBwbGUuY29tIiwiYXVkIjoibmV0Lm1pa3UzLmlkLmFwcGxlLnNlcnZpY2UiLCJleHAiOjE1Njg5Nzg5NTMsImlhdCI6MTU2ODk3ODM1Mywic3ViIjoiMDAxNDg0LjcwOTk5Mjg3MjE1YzQ2MDA4NmE3ODZjN2JkNjY0MGVmLjExMjkiLCJjX2hhc2giOiJjQUo4di0weDg3TTJxZzhJSFBGRExRIiwiYXV0aF90aW1lIjoxNTY4OTc4MzUzfQ.KIJ-nrgtbtrBjBAYnTomWS_yIihPfgI1bwKfMK2aOpFIkotyDrv1GB5WiPCufZwgCJnIXDL4gYXJzgELmII8V5O4xBJ5MtnC_-e_2A5t0GBqF1YjgKIIEKHI2ouXDDANystAsWED8orWmVaYk9HZULQR46FJt6lfX6MPWXmeiMxscglXEo8GdlU_zLcMhrn8A-merukjVs0lTnYKEsZHCqZpoKCRyQByFoXeIAOXJu47AuPLhxdrqv4A8A348coeqMhVkPgmWbanivdc_F0qC085g3dK2IX370KvTq1tEMTps3OGyScSra8quN_ubiswun4x5YDkDESr2ss7K6iUsg

取り出したペイロードの部分をBASE64でデコードするとJSONが登場します。

$ echo "eyJpc3MiOiJodHRwczovL2FwcGxlaWQuYXBwbGUuY29tIiwiYXVkIjoibmV0Lm1pa3UzLmlkLmFwcGxlLnNlcnZpY2UiLCJleHAiOjE1Njg5Nzg5NTMsImlhdCI6MTU2ODk3ODM1Mywic3ViIjoiMDAxNDg0LjcwOTk5Mjg3MjE1YzQ2MDA4NmE3ODZjN2JkNjY0MGVmLjExMjkiLCJjX2hhc2giOiJjQUo4di0weDg3TTJxZzhJSFBGRExRIiwiYXV0aF90aW1lIjoxNTY4OTc4MzUzfQ" | base64 -D
{"iss":"https://appleid.apple.com","aud":"net.miku3.id.apple.service","exp":1568978953,"iat":1568978353,"sub":"001484.70999287215c460086a786c7bd6640ef.1129","c_hash":"cAJ8v-0x87M2qg8IHPFDLQ","auth_time":1568978353

この"sub":"001484.70999287215c460086a786c7bd6640ef.1129"がユーザーごとに割り振られた不変のIDとなります。

実際にコードで書くと以下のような処理になります。

<?php
// ドットで分割
$jwt = explode('.', $_POST['id_token']);   // 0:ヘッダー, 1:ペイロード, 2:署名

// ペイロード部分をBase64デコード
$str = base64_decode($jwt[1]);

// $strは単なる文字列なので、PHP上で扱いやすくする
$payload = json_decode($str);

// ユーザー固有のIDを取り出す
echo $payload['sub'];

Appleから公開されている仕様はこのあたりを参照してください。 developer.apple.com

ただ上記ドキュメントのsub部分を読むと何言ってんだこいつ感があるので、OpenID Connectの仕様書を合わせて読むのが良さそうです。 openid.net

Subject Identifier. A locally unique and never reassigned identifier within the Issuer for the End-User, which is intended to be consumed by the Client

ここでは自前で実装しましたが、実際にはJWTを扱うライブラリが言語毎に存在していますので、そちらを利用するのがお手軽だと思います。改ざんのチェック(verify)なども行ってくれますしね。

その他の言語もJWTの公式サイトの上部メニュー「Libraries」から探すことができます。

Appleサイトでの準備

デベロッパー登録

詳細は割愛しますがAppleへデベロッパー登録を行う必要があります。クレジットカードが必要で毎年11,800円かかります(執筆時点)。ついでにiOSやmacOS用アプリの開発や配信も行えますので、興味があればどうぞw developer.apple.com

昔は1万円未満だったんですけど、徐々に高くなってますね…。個人だと微妙にウッとなるお値段ですw

App IDの作成

最初にこれから「アプリ」を作るぞ!とAppleに意思表示をする必要があります。今回はWebサイトでログインさせるだけですので、ここでいう「アプリ」は概念的な物だと思ってください。

まずはDeveloperサイトへログインします。 developer.apple.com

ログイン後に左メニューの「Certificates, Identifiers & Profiles」をクリック。

どんなアプリを作るか入力します。

  • Platformは「iOS
  • Descriptionは説明文になりますので自分が後から見てわかる文字列であれば何でも良いです。
  • BundleIDはこのアプリを識別するためのユニークなIDです。URLのドメインを反対にしたような文字列を記述します。URLではありませんので存在しないURLのような文字列で構いません。なお世界中の全アプリからユニークである必要があるので通常は自分が所有しているドメイン名を利用します。

まだ次のページに進んではなりません。 ページ下部にある以下の「Sign In with Apple」にチェックをした後に、ページ上部にある青い「Continue」ボタンをクリックします。横にある「Edit」ボタンはクリックしなくてOKです。

確認画面を挟んで最終的に以下のように一覧に表示されていれば成功です。

Identifierの作成

次に「アプリ」で利用する機能の詳細な設定を行います。 先ほどの画面で「Identifiers」の横にある「+」ボタンをクリックします。

「Services IDs」にチェックし「Continue」ボタンをクリック。

ここから具体的な設定項目に入ります。

  • Descriptionは説明文になりますので自分が後から見てわかる文字列であれば何でも良いです。
  • Identireはこの設定を識別するためのユニークなIDです。BundleIDと同様にドメインを反対から書いたような書式になりますが、BundleIDとの重複は許されません。index.htmlのclientIdにはこの文字列を指定します。
  • Sign In with Apple」の横にあるEnableのチェックボックスにチェック

お次は横にある「Edit」ボタンをクリック、以下のような設定画面が開きます。

  • Primary App IDは先ほど作成した「アプリ」を選択します。一つしか無い場合は最初から選ばれています。
  • DomainsにはログインページとredirectURIで指定するページが存在するドメインを入力します。これは複数入力できない点に注意が必要です。
  • ReturnURLsにはredirectURIで指定するURLを指定します。ここに登録していないURLをredirectURIで指定するとエラーとなります。

登録が終わったら「Download」ボタンを押して、指定したドメインの所有者であることを証明するために必要なファイル「apple-developer-domain-association.txt」をダウンロードします。これは後ほど利用します。

あとは画面の指示に従い、最終的に一覧画面に登録したIdentifierが登場すれば成功です。

ドメイン認証

最後にIdentifierの設定で登録したドメインが自分の物であることを証明するために、先ほどダウンロードした「apple-developer-domain-association.txt」を所定の場所にアップロードします。

アップする場所はIdentifierの設定画面で表示されている以下。

ドキュメントルートに.well-knownというディレクトリを作成し、その中にダウンロードしてきた「apple-developer-domain-association.txt」を放り込みます。ディレクトリ名の頭に「.」を付けることをお忘れなく。

以上で登録作業は完了です。

雑感

ログインさせるだけであれば非常に簡単ですね。ただメールアドレスなどの情報が取れないのは正式版では解決するのかな…。あとWeb上でサンプルをあまり見かけないのはAppleへのみかじめ料のせいですかねw

恐らく秋ごろには正式に始まると思いますので、そのあたりになったら実際のサービスへ組み込んでみたいと思います。

参考ページ

Apple

その他