2014-07-10

SalesforceコミュニティでOpenID Connectやってみる【Authorization Code Flow編】

SalesforceをOPにしてOpenID Connectによるソーシャルログインが出来るということなので試してみた。

せっかくなのでSalesforceユーザではなく、コミュニティユーザでソーシャルサインオンをやってみます。

RP側はPython/Flaskで。

 

1. 接続アプリケーションの設定

いつもの接続アプリケーション。

今回はaccess_tokenを使ってUserInfo以外のAPIを叩かないのでid openid refresh_tokenをscopeに入れました。

connectapp-oidc

 

2. RP側プログラムの作成

pythonのバージョンは3.3系。暗号化ライブラリとしてPyCryptoを使ってます。

 

# -*- coding: utf-8 -*- 
from Crypto.Hash import SHA256
from Crypto.PublicKey import RSA
from Crypto.Signature import PKCS1_v1_5
from Crypto.Util.number import bytes_to_long
from flask import Flask, request, session, render_template
from urllib.error import HTTPError
import urllib.request
import base64
import json
import time
import datetime
import random
import hmac

BASE_URL = "https://{community domain}"
CLIENT_ID = "input your client_id"
CLIENT_SECRET = "input your client_secret"
REDIRECT_URI = "input your redirect_uri"

def get_random_string(length):
    """
    ランダムな文字列を生成する
    """
    source_str = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890'
    return "".join([random.choice(source_str) for x in range(length)])

def b64d(b):
    """
    base64urlデコード
    """
    b += "=" * ((4 - len(b)) % 4)
    return base64.urlsafe_b64decode(b)
    
def b64e(b):
    """
    base64urlエンコード
    """
    return base64.urlsafe_b64encode(b).decode().replace("=", "")
    
def b64_to_long(b):
    """
    base64エンコードした文字列をロング型に変換
    """ 
    return bytes_to_long(b64d(b))

def get_rsa_publickey(jwt_header):
    """
    https://{sfdcドメイン}/id/keysから公開鍵を取得する。
    """
    kid = json.loads(b64d(jwt_header).decode())["kid"]
    req = urllib.request.Request(url=BASE_URL + "/id/keys") 
    res = urllib.request.urlopen(req)
    jsonMap = json.loads(res.read().decode())
    
    target_key = {}
    for key in jsonMap["keys"]:
        if key["kid"] == kid:
            target_key = key
            break
    modulus = b64_to_long(target_key["n"])
    exponent = b64_to_long(target_key["e"])
    return RSA.construct((modulus, exponent))
    
def validate_idtoken(jwt):
    """
    JWSの署名検証
    """
    jwt_array = jwt.split(".")
    jws_payload = jwt_array[0] + "." + jwt_array[1]
    #https://{sfdcドメイン}/id/keysから公開鍵の情報を取得
    key = get_rsa_publickey(jwt_array[0])
    #jws_payloadのSHA-256ハッシュ値と復号化した署名が一致しているかどうかを検証
    signer = PKCS1_v1_5.new(key)
    local_hash = SHA256.new(jws_payload.encode())
    return signer.verify(local_hash, b64d(jwt_array[2]))

def create_signature(json_map):
    """
    id,issued_atからSignatureを生成し署名を検証する
    """
    payload = json_map["id"] + json_map["issued_at"]
    signature = hmac.new(CLIENT_SECRET.encode(), payload.encode(), SHA256).digest()
    return base64.b64encode(signature).decode()

def get_idtoken(code):
    """
    id_token+access_tokenの取得
    """
    #access_tokenの取得
    token_req_param = {
        "grant_type" : "authorization_code",
        "client_id" : CLIENT_ID,
        "client_secret" : CLIENT_SECRET,
        "redirect_uri" : REDIRECT_URI,
        "code" : code
    }
    req = urllib.request.Request(
        url=BASE_URL + "/services/oauth2/token", 
        data=urllib.parse.urlencode(token_req_param).encode(),
        headers={"Content-Type" : "application/x-www-form-urlencoded"}
    )
    
    try:
        res = urllib.request.urlopen(req)
        return json.loads(res.read().decode())
    except HTTPError as error:
        print(error.reason)
    
    return {}
 
def get_userinfo():
    """
    ユーザ情報の取得
    """
    #access_tokenの取得
    req = urllib.request.Request(
        url=session["instance_url"] + "/services/oauth2/userinfo",
        headers={"Authorization" : "Bearer " + session["access_token"]}
    )
    
    try:
        res = urllib.request.urlopen(req)
        return json.loads(res.read().decode())
    except HTTPError as error:
        print(error.reason)
    
    return {}
 
app = Flask(__name__)
app.debug = True
app.secret_key = get_random_string(32)

@app.route("/")
def index():
    """
    Authorization Requestの為の初期ページ
    """
    request_param = {
        "response_type" : "code",
        "client_id" : CLIENT_ID,
        "redirect_uri" : REDIRECT_URI,
        "scope" : "id openid refresh_token",
        "state" : get_random_string(32),
        "nonce" : get_random_string(32)
    }
    session["state"] = request_param["state"]
    session["nonce"] = request_param["nonce"]
    authorization_url = BASE_URL + "/services/oauth2/authorize?" + urllib.parse.urlencode(request_param)
    return render_template("index.html", authorization_url=authorization_url)

@app.route("/callback")
def callback():
    """
    Authorization Requestに対するコールバックを受け取る
    """
    #state値が正しいかどうか確認
    if session["state"] != request.args.get("state"):
        return "invalid parameter error!!"
    #id_token(+access_token)の取得
    json_map = get_idtoken(request.args.get("code"))
    
    #signature検証
    if json_map["signature"] != create_signature(json_map):
        return "invalid parameter error!!"
    
    #id_tokenの検証
    if validate_idtoken(json_map["id_token"]):
        jwt_array = json_map["id_token"].split(".")
        jwt_claim = json.loads(b64d(jwt_array[1]).decode())
        
        now = int(time.mktime(datetime.datetime.now().utctimetuple()))
        exp = int(jwt_claim["exp"])
        #token有効期限の確認
        if now > exp:
            return "code is expired!!"

        if jwt_claim["nonce"] != session["nonce"]:
            return "invalid parameter error!!"

        signature = SHA256.new(json_map["refresh_token"].encode()).digest()
        #signature = SHA256.new(json_map["access_token"].encode()).digest() #at_hash
        #signature = SHA256.new(request.args.get("code").encode()).digest() #c_hash
        print(json_map)
        print(jwt_claim)
        signature = b64e(signature[:int(len(signature)/2)]).replace("=", "")
        #hash, issuer, audienceの確認
        if signature == jwt_claim["c_hash"] and jwt_claim["iss"] == "https://login.salesforce.com" and jwt_claim["aud"] == CLIENT_ID:
            session["id"] = jwt_claim["sub"]
            session["access_token"] = json_map["access_token"]
            session["instance_url"] = json_map["instance_url"]
            userinfo = get_userinfo()
            return "hello " + userinfo["name"] + "<br/>login is successful!!"
            
    return "validation error!!"


if __name__ == "__main__":
    app.run()

 

index.html

<!DOCTYPE html>
<html>
<head></head>
<body>
  <div>
    <a href="{{ authorization_url }}">Salesforceコミュニティでログイン</a>
  </div>
</body>
</html>

 

 

最初は通常のOAuth2.0と同じ。

scopeはid openid refresh_tokenを指定。

idはuserinfo取得用、openidはid_token取得用、refresh_tokenはJWS検証用

※本当はcodeのハッシュを検証に利用するのですが、現時点ではrefresh_tokenのハッシュになっているっぽいです。なのでコードもrefresh_tokenになっていますが、issueが解決されたらcodeのハッシュを取るようにしてください。

 

CSRF対策にSessionと紐付いたstate値とnonce値を入力。

(nonceはAuthorization Code Flowでは入れる必要は無いです。Implicit Flowでは必須。)

 

callbackで戻ってきたら、state値が合っているかを検証。

合っていればOAuth2.0と同じくcode値を使ってid_token+access_tokenを取得します。

 

レスポンスは以下のようになります。

{
    "scope": "id openid refresh_token",
    "issued_at": "1404657905515",
    "instance_url": "https://******.salesforce.com",
    "id_token": "eyJhb*******.eyJle**************.DNyJ_E_*************",
    "id": "https://login.salesforce.com/id/00D************/005***********",
    "signature": "Ha2CB4t89xks6HQ3FCOOQQ1GzlZG5Pgpuok2513otMk=",
    "token_type": "Bearer",
    "access_token": "00DA0*********!AQoAQG2FfyQ**************************",
    "refresh_token": "5Aep861Y************************************"
}

 

取得したid_tokenはJWT形式なのでピリオド区切りで順にヘッダ、クレームセット、署名部分にそれぞれ分解。

ヘッダとクレームセットはjsonをbase64エンコードしたものなのでデコードします。

ヘッダ↓

{
    "alg": "RS256",
    "typ": "JWT",
    "kid": "188"
}

クレームセット↓

{
    "nonce": "UoiCgJbKH1jzOZ3K4bdiwRRo4MYN5vJ2",
    "iss": "https://login.salesforce.com",
    "aud": "3MV****************************************",
    "iat": 1404661954,
    "c_hash": "mp4sCQW6O1GvqzpyBcXA7Q",
    "sub": "https://login.salesforce.com/id/00D************/005***********",
    "exp": 1404662074
}

 

クレームセット部分を使って現在のタイムスタンプ値とexp値を比較して期限が切れていないかとnonce値が合っているかをチェックします。

合っていればhttps://login.salesforce.com/id/keysに表示されている公開鍵情報からヘッダのkidに対応する鍵を取得します。

 

id-keys

 

対象の公開鍵を使って署名検証を行いOKであれば初めて認証成功となります。

Salesforceは現時点で署名方式が"RS256"なので

Sha256Hash(JWTヘッダ+ "." + JWTクレームセット) = RSADecrypt(JWS)

となればOK。

 

ちなみに、一応access_tokenのレスポンス内のsignature検証もしていてこちらは

Sha256(id値 + issued_at値) = signature値

となっていればOK。

 

上記の処理内でAuthorizatoin Code FlowでClient Secretが機密にされているという理由で

検証不要な項目もあると思いますが、全部やろうとするとこんな感じになります。

 

あと、ソースコードは各エンドポイントやらissuer値がベタ書きですが、本当は以下のDiscoveryEndpointの情報を参照します。

https://login.salesforce.com/.well-known/openid-configuration

{
    "issuer": "https://login.salesforce.com",
    "authorization_endpoint": "https://login.salesforce.com/services/oauth2/authorize",
    "token_endpoint": "https://login.salesforce.com/services/oauth2/token",
    "revocation_endpoint": "https://login.salesforce.com/services/oauth2/revoke",
    "userinfo_endpoint": "https://login.salesforce.com/services/oauth2/userinfo",
    "jwks_uri": "https://login.salesforce.com/id/keys",
    "scopes_supported": [
        "id",
        "api",
        "web",
        "full",
        "chatter_api",
        "visualforce",
        "refresh_token",
        "openid",
        "profile",
        "email",
        "address",
        "phone",
        "offline_access"
    ],
    "response_types_supported": [
        "code",
        "token",
        "token id_token"
    ],
    "subject_types_supported": [
        "public"
    ],
    "id_token_signing_alg_values_supported": [
        "RS256"
    ],
    "display_values_supported": [
        "page",
        "popup",
        "touch"
    ],
    "token_endpoint_auth_methods_supported": [
        "client_secret_post",
        "private_key_jwt"
    ]
}

 

3. 実際の動き

flaskアプリを起動してhttp://localhost:5000とかにアクセスすると認証用リンクが出るのでクリックします。

auth-link

 

コミュニティ用のログイン画面が出るのでログインします。

 

oidc_login

いつもの認可画面

authorization_screen

許可するとリダイレクトしてid_tokenとaccess_tokenをして検証やら何やらをしてOKであれば画面にユーザ名が出ます。

successful-login-oidc

 

こんな感じで基本的にOAuth2.0に色々な検証が加わったものがOpenID Connectなので

OpenIDやSAMLよりかは設定や検証がだいぶ楽になってます。

個人的にハマったのは公開鍵部分ぐらいで、これも暗号化の仕組みわかってて慣れてる人からしたら

大したものじゃないと思います。

 

SFDCがOpenID ConnectのOPになれるということは既存アプリケーションと連携しやすくなるということで、

同業種の別の会社のWebサービスと自社のSFDCコミュニティがソーシャルサインオンで連携できるとか

そういうこともやりやすくなるなーと思ったり。

このエントリーをはてなブックマークに追加