Sign in with AppleのJWTをサーバーサイドで検証する


これはなに?

モバイル端末のapp側で Sign in with Apple を実施、ここで入手したトークンをサーバ側で検証する方法についてのメモ。

環境

  • ruby: 2.6
  • rails: 5.3
  • jwt: 2.1

概要

サーバ側での実施項目は以下。

  • 公開鍵を取得、OpenSSL::PKey::RSAを作成する
  • JWTをデコードする
  • 内容を検証する

公開鍵を取得、OpenSSL::PKey::RSAを作成する

https://appleid.apple.com/auth/keysから公開かぎを取得、作成する。
このURLをGETすると、複数レコードが返信される。
ここから、クライアントから送られてくる kid を使用して対応するレコードを絞り込む。

publick_key.rb
module Apple
  class PublicKey
    def create_key(kid)
      OpenSSL::PKey::RSA.new(sequence(kid).to_der)
    end

    private

    def sequence(kid)
      OpenSSL::ASN1::Sequence.new([
        OpenSSL::ASN1::Integer.new(openssl_bn(modules(kid))),
        OpenSSL::ASN1::Integer.new(openssl_bn(exponent(kid)))
      ])
    end

    def openssl_bn(n)
      n = n + '=' * (4 - n.size % 4) if n.size % 4 != 0
      decoded = Base64.urlsafe_decode64 n
      unpacked = decoded.unpack('H*').first
      OpenSSL::BN.new unpacked, 16
    end

    def modules(kid)
      public_key(kid)&.[]("n")
    end

    def exponent(kid)
      public_key(kid)&.[]("e")
    end

    def public_key(kid)
      public_keys_json["keys"].each do |record|
        return record if record["kid"] === kid
      end
    end

    def public_keys_json
      @public_keys_json ||= JSON.parse(public_keys)
    end

    def public_keys
      @public_keys ||= HTTPClient.new.get(Settings.idps.apple.public_key.url).body
      # url: https://appleid.apple.com/auth/keys
    end
  end
end

JWTをデコードする

クライアント側から送られる JWT デコードする。
まず、検証なしでデコードし kid を取り出す。
これを使用して、公開鍵を取得。
この公開鍵を使用して、検証しながらデコードする。

access_token_analyser.rb
module Apple
  class AccessTokenAnalyser
    def initialize(access_token)
      @access_token = access_token
    end

    def decode_token
      JWT.decode(@access_token, rsa_public_key, true, { algorithm: 'RS256' })
    rescue => e
      Rails.logger.info %(exception occured. #{e.message})
      nil
    end

    private

    def kid_from_token
      decode_token_without_verify&.[](1)&.[]("kid")
    end

    def decode_token_without_verify
      @decode_token_without_verify ||= JWT.decode(@access_token, nil, false)
    rescue => e
      Rails.logger.info %(exception occured. #{e.message})
      nil
    end

    def rsa_public_key
      Apple::PublicKey.new.create_key(kid_from_token)
    end
  end
end

検証する

appleのdocumentによると、以下の項目を検証すべしとある。
https://developer.apple.com/documentation/signinwithapplerestapi/verifying_a_user

To verify the identity token, your app server must:

  • Verify the JWS E256 signature using the server’s public key
  • Verify the nonce for the authentication
  • Verify that the iss field contains https://appleid.apple.com
  • Verify that the aud field is the developer’s client_id
  • Verify that the time is earlier than the exp value of the token

今回、nonceは使用していないので、それ以外を検証する。
期限切れの場合、JWT.decode で例外が発生する。

apple_login_service.rb
module Apple
  class AppleLoginService
    def verify_token(params)
      verify_access_token_local(params[:access_token])
    rescue => e
      Rails.logger.info %(exception occured. #{e.message})
      false
    end

    private

    def verify_access_token_local(access_token)
      access_token_analyser = Apple::AccessTokenAnalyser.new(access_token)
      unless token_json = access_token_analyser.decode_token
        return false
      end

      unless verify_iss(token_json.first['iss'])
        return false
      end

      unless verify_aud(token_json.first['aud'])
        return false
      end
      token_json.first['sub']
    end

    def verify_iss(iss)
      iss == 'https://appleid.apple.com'
    end

    def verify_aud(aud)
      aud == aud_value
    end

    def aud_value
      (Rails.env.development?)? 'host.exp.Exponent' : 'myapp'  # myapp value
    end
  end
end

audの検査

今回、クライアントの開発に expo.io を使用している。
この場合、開発環境とその他の環境(expo client を使用するか否か)で、aud の値が異なる。
開発環境は expo client で host.exp.Exponent
それ以外の場合は、Apple developer console の Certificates, Identifiers & Profiles > Identifiers で定義した IDENTIFIER に等しいことを検査。