LoginSignup
16
10

More than 3 years have passed since last update.

Authlete を使って CIBA 対応の認可サーバーを作る

Last updated at Posted at 2019-02-13

1. 概要

この文書では、Authlete を用いて CIBA (Client initiated Backchannel Authentication) フロー対応の認可サーバーを作る方法について解説します。

1.1. 前提

事前に CIBA の概要について把握していることが必須となります。必要に応じて、以下のドキュメントを参考にしてください。

◯ 事前に OAuth 2.0・OpenID Connect および Authlete の概要について把握しておくことを推奨します。必要に応じて、以下のドキュメントを参考にしてください。

1.2. システム構成

以下のように Authlete をバックエンドに構える認可サーバーを構築します。

system2.png

1.3. 実装例について

  • 本記事で紹介する実装例は、あくまでも実装のイメージを掴むためのものです。処理の流れを把握し易くするために、意図的に最適化を行なっていない部分があります。より洗練された実装については、以下のソースコードを参考にしてください。

  • 本記事で紹介する実装例には以下の制約があることに注意してください。

    • 実装例の認可サーバーでは OAuth 2.0 および OpenID Connect のフローはサポートされません。 (CIBA フローのみがサポートされます。)

    • 実装例のバックチャネル認証エンドポイントでは、一部の OPTIONAL なリクエストパラメーター (login_hint_tokenid_token_hintacrs など) はサポートされません。

    • 実装例のバックチャネル認証エンドポイントおよびトークンエンドポイントでは、クライアント認証方式として client_secret_basic のみがサポートされます。

1.4. その他

2. 実装

CIBA フローでは、バックチャネル認証エンドポイントトークンエンドポイントの二つが中心的な役割を果たすことになります。以下では、Authlete を用いてこれらのエンドポイントを実装する方法について解説します。

2.1. Authlete を用いた場合の処理フロー

Authlete を用いて CIBA フロー実装した場合、各エンドポイントにおける処理フローは以下のようになります。

2.1.1. バックチャネル認証エンドポイントの処理フロー

(便宜上、異常系のフローは省略してあります。)
ciba-bc-auth-endpoint.png

バックチャネル認証エンドポイントでは、以下の Authlete API が利用されていることを確認してください。

  • /api/backchannel/authentication API
  • /api/backchannel/authentication/issue API
  • /api/backchannel/authentication/complete API

また、上記処理フロー図では省略されていますが、当該エンドポイントからは /api/backchannel/authentication/fail API が呼び出されることもあるので注意してください。 (詳細は、2.2.2. ヒントによるエンドユーザーの識別、**2.2.3. リクエストパラメーターのチェック**をご覧ください。)

2.1.2. トークンエンドポイントの処理フロー

トークンエンドポイントの処理フローは非常にシンプルです。OAuth 2.0 をサポートする場合と同様1、認可サーバーはクライアントから受け付けたトークンリクエストの内容を Authlete の /api/auth/token API に渡し、当該 API からのレスポンスにしたがってトークンレスポンスをクライアントに返却するだけです。

2.2. バックチャネル認証エンドポイントの実装

上記の処理フローの通り、バックチャネル認証エンドポイントでは様々な処理を行う必要がありますが、個々の処理を実装する前に基本的な事項を押さえておきます。

✔︎ 認証リクエスト
バックチャネル認証エンドポイントに対して送られるリクエストは、認証リクエスト (Authentication Request) と呼ばれます。仕様により、認証リクエストのリクエストメソッドは POST でなければならず、その Content-Typeapplication/x-www-form-urlencoded でなければなりません。

✔︎ クライアント認証
バックチャネル認証エンドポイントでは、クライアント認証を行うことが必須となっています。こちらの記事で解説されているように、CIBA フローにおいて選択できるクライアント認証方式はいくつかありますが、今回の実装では簡単のため、client_secret_basic (Basic 認証) のみをサポートすることにします。また、バックチャネル認証エンドポイントにおいてサポートするクライアント認証方式は、トークンエンドポイントにおいてサポートするクライアント認証方式と同一でなければならないことにも注意してください。

以上を踏まえて、バックチャネル認証エンドポイントの雛形を作っておきます。以下は、Java (JAX-RS) によるサンプル実装になります。

BackchannelAuthenticationEndpoint.java
@Path("/api/backchannel/authentication")
public class BackchannelAuthenticationEndpoint
{
    /**
     * バックチャネル認証エンドポイント。
     * Content-Type が application/x-www-form-urlencoded である POST リクエストを受け付ける。
     * また、この実装では、クライアント認証方式として client_secret_basic (Basic 認証) のみを想定している。
     *
     * @param authorization
     *         認証リクエストの Authorization ヘッダーに含まれる値。
     *         クライアントの認証情報が設定されている想定。
     *         
     * @param parameters
     *         認証リクエストのリクエストボディに含まれる値。
     */
    @POST
    @Consumes(MediaType.APPLICATION_FORM_URLENCODED)
    public Response post(@HeaderParam(HttpHeaders.AUTHORIZATION) String authorization, MultivaluedMap<String, String> parameters)
    {
        try
        {
            // 主要な処理。
            return doProcess(authorization, parameters);
        }
        catch (WebApplicationException e)
        {
            // 既知のエラー。
            return e.getResponse();
        }
        catch (Throwable t)
        {
            // 未知のエラー。
            return ResponseUtil.internalServerError("Unknown error occurred.");
        }
    }

    private Response doProcess(String authorization, MultivaluedMap<String, String> parameters)
    {
        // 主要な処理はここに実装。
    }
}

以下のセクションでは、この雛形をベースに個々の処理を追加していきます。

2.2.1. 認証リクエストの検証

バックチャネル認証エンドポイントで最初に行うべき処理は、認証リクエストの検証処理になります。主な処理手順は以下のようになります。

  1. authorization パラメーターからクライアントの認証情報を取り出す。
  2. 取り出したクライアントの認証情報と parameters パラメーターを用いて Authlete の /api/backchannel/authentication API を呼ぶ。これにより、認証リクエストの検証処理を Authlete に委譲できる
  3. /api/backchannel/authentication API からのレスポンスに含まれる action パラメーターの内容に応じて、適切な処理を行う。

以上を踏まえ、doProcess() メソッドに処理を追加します。

private Response doProcess(String authorization, MultivaluedMap<String, String> parameters)
{
    // Authorization ヘッダーに設定された値をパースして、クライアントの認証情報を取り出す。
    BasicCredentials credentials = BasicCredentials.parse(authorization);

    // クライアントの認証情報から「クライアント ID」と「クライアントシークレット」を取り出す。
    String clientId     = credentials == null ? null : credentials.getUserId();
    String clientSecret = credentials == null ? null : credentials.getPassword();

    // Authlete の /api/backchannel/authentication API を呼び出す。
    // これにより、認証リクエストの検証処理を Authlete に委譲できる。
    BackchannelAuthenticationResponse response =
        callBackchannelAuthenticationApi(parameters, clientId, clientSecret);

    // レスポンス内の action パラメーターは、「この認可サーバーが次にどのような処理を行うべきか」を示している。
    BackchannelAuthenticationResponse.Action action = response.getAction();

    // クライアントへ返却すべきレスポンスの内容。 (この内容は action パラメーターに依存して変わる。)
    String content = response.getResponseContent();

    // action パラメーターに応じて処理を行う。
    switch (action)
    {
        case INTERNAL_SERVER_ERROR:
            // Authlete API の呼び出しが不適切であったか、Authlete サーバー側で何らかのエラーが発生した場合。
            // 500 Internal Server Error を返却。
            return ResponseUtil.internalServerError(content);

        case UNAUTHORIZED:
            // クライアント認証でエラーが生じた場合。 (例: クライアント ID の値が間違っていた場合など)
            // 401 Unauthorized を返却。
            return ResponseUtil.unauthorized(content, "Basic realm=\"backchannel/authentication\"");

        case BAD_REQUEST:
            // 認証リクエストに不正があった場合。 (例: 必須のリクエストパラメーターが含まれていなかった場合など)
            // 400 Bad Request を返却。
            return ResponseUtil.badRequest(content);

        case USER_IDENTIFICATION:
            // 認証リクエストに問題がなかった場合。
            // この場合、handleUserIdentification() メソッドを呼び出し、処理を続行する。
            return handleUserIdentification(response);

        default:
            // 上記以外の action の場合。これは起こり得ない。
            // 500 Internal Server Error を返却。
            return ResponseUtil.internalServerError("Unknown action returned from /api/backchannel/authentication API.");
    }    
}

認証リクエストに問題がなかった場合、 Authlete の /api/backchannel/authentication API からは action=USER_IDENTIFICATION のレスポンスが返却されます。この場合、認可サーバー側では handleUserIdentification() メソッドを呼び出し、処理を続行します。action の値がそれ以外の場合は、何らかのエラーが発生したこと意味しますので、適宜エラーレスポンスを生成し、それをクライアントに返却します。

2.2.2. ヒントによるエンドユーザーの識別

上記処理において、Authlete の /api/backchannel/authentication API から action=USER_IDENTIFICATION のレスポンスが返却された場合、認可サーバーは認証リクエストに含まれているヒントを利用して、対象のエンドユーザーを特定しなければなりません。仕様上、認証リクエストに含まれるヒントは login_hintlogin_hint_tokenid_token_hint のいずれかになるのですが、今回の実装では簡単のため、login_hint のみをサポートするものとします。また、実際の login_hint の値としては、ユーザーのサブジェクト、Email アドレス、電話番号のいずれかが指定できるものとします。

与えれらた login_hint を用いてユーザーが特定できた場合、認可サーバーはそのまま処理を続行しますが、ユーザーが特定できなかった場合は、Authlete の /api/backchannel/authentication/fail API を呼び出してエラーレスポンスを生成し、それをクライアントに返却します。

以上を踏まえ、handleUserIdentification() メソッドに処理を追加します。

private Response handleUserIdentification(BackchannelAuthenticationResponse baRes)
{
    // 認証リクエストに含まれているヒントを用いてユーザーを識別する。
    User user = identifyUserByHint(baRes);
}

private User identifyUserByHint(BackchannelAuthenticationResponse baRes)
{
    // 認証リクエストに含まれていたヒントの種類。
    // 値は LOGIN_HINT、LOGIN_HINT_TOKEN、ID_TOKEN_HINT のいずれか。
    UserIdentificationHintType hintType = baRes.getHintType();
  
    // 認証リクエストに含まれていたヒント。
    String hint = baRes.getHint();
    
    // ヒントを利用してユーザーを見つける。
    User user = getUserByHint(hintType, hint);

    if (user != null)
    {
        // ユーザーが見つかった。
        return user;
    }

    // 与えられたヒントを利用したが、対象のユーザーは見つからなかった。
    // この場合、Authlete の /api/backchannel/authentication/fail API を呼び出して
    // 適切なレスポンスを生成し、例外としてスローする。
    // (この例外は post() メソッドにてキャッチされることに注意。)
    throw backchannelAuthenticationFail(baRes.getTicket(), Reason.UNKNOWN_USER_ID);
}

private User getUserByHint(UserIdentificationHintType hintType, String hint)
{
    if (hintType != UserIdentificationHintType.LOGIN_HINT)
    {
        // 今回の実装では、 login_hint のみをサポートしているので、それ以外のヒントが与えられても無視する。
        return null;
    }

    // login_hint を使って、ユーザーを特定する。
    // 以下の実装では、ユーザーのサブジェクト・ Email アドレス・電話番号のいずれかが login_hint になり得ると想定している。

    // 最初に、login_hint の値がユーザーのサブジェクトであると仮定して、ユーザーを探す。
    User user = UserDao.getBySubject(hint);

    if (user != null)
    {
        // ユーザーが見つかった。
        return user;
    }

    // 次に、login_hint の値がユーザーの Email アドレスであると仮定して、ユーザーを探す。
    user = UserDao.getByEmail(hint);

    if (user != null)
    {
        // ユーザーが見つかった。
        return user;
    }

    // 最後に、login_hint の値がユーザーの電話番号であると仮定して、ユーザーを探す。
    return UserDao.getByPhoneNumber(hint);
}

private WebApplicationException backchannelAuthenticationFail(String ticket, BackchannelAuthenticationFailRequest.Reason reason)
{
    // Authlete の /api/backchannel/authentication/fail API を呼び出して、適切なレスポンスを生成する。
    Response response = createBackchannelAuthenticationFailResponse(ticket, reason);

    // レスポンスにもとづいた例外を生成する。
    return new WebApplicationException(response);
}

private Response createBackchannelAuthenticationFailResponse(String ticket, BackchannelAuthenticationFailRequest.Reason reason)
{
    // Authlete の /api/backchannel/authentication/fail API を呼び出す。
    BackchannelAuthenticationFailResponse response = callBackchannelAuthenticationFail(ticket, reason);

    // レスポンス内の action パラメーターは、「この認可サーバーが次にどのような処理を行うべきか」を示している。
    BackchannelAuthenticationFailResponse.Action action = response.getAction();

    // クライアントへ返却すべきレスポンスの内容。 (この内容は action パラメーターに依存して変わる。)
    String content = response.getResponseContent();

    // action パラメーターに応じて処理を行う。
    switch (action)
    {
        case INTERNAL_SERVER_ERROR:
            // 500 Internal Server Error を返却。
            return ResponseUtil.internalServerError(content);

        case FORBIDDEN:
            // 403 Forbidden を返却。
            return ResponseUtil.forbidden(content);

        case BAD_REQUEST:
            // 400 Bad Request を返却。
            return ResponseUtil.badRequest(content);

        default:
            // 上記以外の action の場合。これは起こり得ない。
            // 500 Internal Server Error を返却。
            return ResponseUtil.internalServerError("Unknown action returned from /api/backchannel/authentication/fail.");
    }
}

2.2.3. リクエストパラメーターのチェック

handleUserIdentification() メソッド内では、エンドユーザーの識別処理に加えて、以下のリクエストパラメーターのチェックも併せて行う必要があります。

2.2.3.1. login_hint_token

login_hint_token がヒントとして利用される場合、その有効期限のチェックを行う必要があります。ただし、今回の実装では login_hint_token は利用されないものと想定しているので、このチェックは省略します。

2.2.3.2. ユーザーコード

2.2.3.2.1. ユーザーコードの概念

上記実装例では、login_hint に指定され得る値として、ユーザーのサブジェクト、電話番号、Eamil アドレスのいずれかを想定していますが、これらは第三者が推測し得る値です。仮に悪意のあるクライアントが login_hint として有効な値を見つけてしまい、それを含めて認証リクエストを何度も送信した場合、何が起きるでしょうか?認証リクエストに不正がない限り、認可サーバーは各リクエスト毎に認証デバイスとのやりとりを行い、その度にエンドユーザーは認証デバイス上で認可を求められることになるでしょう。

ユーザーコード (user_code) は、このような不正な認証リクエストを防止するために導入されたリクエストパラメーターです。仕様書によると、その定義は以下のようになっています。

user_code
OPTIONAL. A secret code, such as password or pin, known only to the user but verifiable by the OP. The code is used to authorize sending an authentication request to user's authentication device. This parameter should only be present if client registration parameter backchannel_user_code_parameter indicates support for user code.

太字で示されているように、ユーザーコードはそのユーザーだけが知っている秘密のコード (例: パスワード、ピンコード) であり、加えて、OpenID プロバイダー (認可サーバー) にとって検証可能であるという性質も持ち合わせています。

話は冒頭の例に戻りますが、仮に悪意のあるユーザーが login_hint の値を推測できたとしても、ユーザーコードの値まで推測することは難しいと考えられるため、そのようなユーザーが正しいユーザーコードを含む認証リクエストを送ることはできないはずです。したがって、認可サーバー側でユーザーコードの検証処理を行なっていれば、そのような不正な認証リクエストはリジェクトできるということになります。

2.2.3.2.2. ユーザーコードの検証

では、ユーザーコードを検証するにあたり、具体的にどのような処理が必要になるのでしょうか?

まず、Authlete 側で行われるユーザーコードの検証処理についてですが、認可サーバー側から /api/backchannel/authentication API が呼び出されると、Authlete は以下のようなチェックを行います。

// もしサービス (認可サーバー) の backchannelUserCodeParameterSupported の値が true で
// かつクライアントの bcUserCodeRequired の値が true であるなら。
if (service.isBackchannelUserCodeParameterSupported() && client.isBcUserCodeRequired())
{
    // もしユーザーコードが認証リクエストに含まれていなかったら。
    if (isUserCodeContainedInRequestParameters() == false)
    {
        // エラーレスポンス (action=BAD_REQUEST) を認可サーバーに返却。
        throw missingUserCodeError();
    }
}

すなわち Authlete は、ユーザーコードがその認可サーバーによってサポートされ (service.isBackchannelUserCodeParameterSupported() の値が true)、かつ、ユーザーコードがそのクライアントに対して必須 (client.isBcUserCodeRequired() の値が true)である場合に限り、認証リクエストにユーザーコードが含まれているかどうかのチェックを行います。この時、もし認証リクエストにユーザーコードが含まれていなかったら、Authlete は action=BAD_REQUEST のレスポンスを認可サーバーに返却します。したがって、この場合、doProcess() メソッド内では、switch 文中の case BAD_REQUEST に対応する処理が実行されることに注意してください。

一方、我々が現在実装している処理は、handleUserIdentification() メソッド、すなわち、上記 switch 文中の case USER_IDENTIFICATION に対応する処理です。つまり、処理がここまで到達したということは、(Authlete 側では) 上記擬似コードにおける throw missingUserCodeError(); の処理が実行されなかったということになります。これはつまり、現在の状況が以下のいずれかケースに当てはまるということを意味します。

✔︎ Case 1
Authlete 側でユーザーコードのチェックが行われた結果、リクエストパラメーターにユーザーコードが含まれていた (= 上記擬似コードにおいて service.isBackchannelUserCodeParameterSupported() && client.isBcUserCodeRequired() の値が true であり、かつ isUserCodeContainedInRequestParameters() の値が true であった)

✔︎ Case 2
そもそも Authlete 側でユーザーコードのチェックが行われなかった (= 上記擬似コードにおいて service.isBackchannelUserCodeParameterSupported() && client.isBcUserCodeRequired() の値が false であった)

Case 1 の場合、認証リクエストにはユーザーコードが含まれている必要がありますが、これは既に Authlete によって保証されています。したがって、この場合、認可サーバー側では認証リクエストに含まれているユーザーコードの値が正しいかどうかのみをチェックすればよいということになります。

Case 2 の場合、ユーザーコードが認証リクエストに含まれている必要はないで、この場合のチェックは省略するものとします2

以上を踏まえ、handleUserIdentification() に処理を追加します。

private Response handleUserIdentification(BackchannelAuthenticationResponse baRes)
{
    ...

    // ユーザーコードのチェック。
    checkUserCode(baRes, user);
}

private void checkUserCode(BackchannelAuthenticationResponse baRes, User user)
{
    // ユーザーコードが必須ではない場合。 (= Case 1 ではない場合)
    //
    // 注意: isUserCodeRequired() メソッドは、サービス (認可サーバー) の backchannelUserCodeParameterSupported と
    // クライアントの bcUserCodeRequired の両者の値が true である場合に true, そうでない場合は false 
    // を返す。
    if (baRes.isUserCodeRequired() == false)
    {
        // 何もチェックしない。
        return;
    }

    // 認証リクエストに含まれていたユーザーコードの値。
    String userCodeInRequest = baRes.getUserCode();

    // 正しいユーザーコードの値。
    String userCode = user.getCode();

    // もし両者の値が合致したら。
    if (userCodeInRequest.equals(userCode))
    {
        // 正しいユーザーコードが認証リクエストに含まれていた。
        return;
    }

    // 不正なユーザーコードが認証リクエストに含まれていた。
    // この場合、Authlete の /api/backchannel/authentication/fail API を呼び出して
    // 適切なレスポンスを生成し、例外としてスローする。
    // (この例外は post() メソッドにてキャッチされることに注意。)
    throw backchannelAuthenticationFail(baRes.getTicket(), Reason.INVALID_USER_CODE);
}

2.2.3.3. バインディングメッセージ

2.2.3.3.1. バインディングメッセージの概念

あるクライアント X が CIBA フローを開始すると、エンドユーザーは認証デバイス上でクライアント X を認可するよう要求されるわけですが、この時エンドユーザーは「認証デバイス上で認可しようとしている対象がクライアント X である」ことをどのように確認すればよいのでしょうか?換言すれば、「認証デバイス上で認可しようとしている対象がクライアント X 以外のアプリではない」ことをエンドユーザーはどのように確認すればよいのでしょうか?

バインディングメッセージ (binding_message) は、このような背景から導入されたパラメーターになります。具体的なユースケースとしては、以下のようなシーンが想定されます。

ciba-bm-usecase3.png

①エンドユーザーが CIBA フローを利用した決済処理を申し出ます。

②コンビニ側で決済処理を受け付けます。この時、レジ端末 (クライアントアプリ) 上には 「sRj89xCg」 というバインディングメッセージが表示されます。

③レジ端末 (クライアントアプリ) は認可サーバーに認証リクエストを送ります。 この時、バインディングメッセージもリクエストに含めて送られます。

④認可サーバーは認証デバイスとやりとりを開始します。この時、バインディングメッセージも認証デバイスに伝えられます。

⑤エンドユーザーは認証デバイス上でクライアントを認可するよう求められます。この時、「sRj89xCg」というバインディングメッセージも認証デバイス上に表示されます。

⑥レジ端末上で表示されているバインディングメッセージと、認証デバイス上で表示されているバインディングメッセージが合致しているので、エンドユーザーは「今認証デバイス上で認可しようとしている対象が確かに目の前にあるクライアントアプリ (レジ端末) である」ということが確認できます。

2.2.3.3.1. バインディングメッセージの検証

さて、ここまでの説明でバインディングメッセージの概念は理解できたかとは思いますが、認可サーバー側ではバインディングメッセージに関してどのようなチェックを行うべきなのでしょうか?

以下は CIBA Core 仕様書からの抜粋になります。

binding_message
OPTIONAL. A human readable identifier or message intended to be displayed on both the consumption device and the authentication device to interlock them together for the transaction by way of a visual cue for the end-user. This interlocking message enables the end-user to ensure that the action taken on the authentication device is related to the request initiated by the consumption device. The value SHOULD contain something that enables the end-user to reliably discern that the transaction is related across the consumption device and the authentication device, such as a random value of reasonable entropy (e.g. a transactional approval code). Because the various devices involved may have limited display abilities and the message is intending for visual inspection by the end-user, the binding_message value SHOULD be relatively short and use a limited set of plain text characters. The invalid_binding_message defined in Section 13 is used in the case that it is necessary to inform the Client that the provided binding_message is unacceptable.

ここから分かるように、仕様書にはバインディングメッセージ対する MUST の条件は規定されていません。ただし太字の部分にあるように、バインディングメッセージの値が「比較的短く」「限られたプレーンテキストの組み合わせ」であるべきだということはわかります。また、仕様書では言及されていませんが、「認証デバイス上に不適切な文字列が表示されないよう、バインディングメッセージの値をチェックする」ということも考えられます。いずれにせよ、バインディングメッセージのチェックに関する必須の処理はなく、どのようなチェックを行うかは認可サーバー側の要件次第ということになります。

以上を踏まえ、ここでは、バインディングメッセージの文字数チェックを行う処理を handleUserIdentification() に追加しておきます。

/**
 * バインディングメッセージの最大文字数。
 */
private static String MAX_BINDING_MESSAGE_LENGTH = 100;

...

private Response handleUserIdentification(BackchannelAuthenticationResponse baRes)
{
    ...

    // バインディングメッセージのチェック。
    checkBindingMessage(baRes);
}

private void checkBindingMessage(BackchannelAuthenticationResponse baRes)
{
    // 認証リクエストに含まれていたバインディングメッセージ。
    String bindingMessage = baRes.getBindingMessage();

    // もしバインディングメッセージが空だったら。
    if (bindingMessage == null || bindingMessage.length() == 0)
    {
        // チェックは行わない。
        return;
    }

    // もしバインディングメッセージの文字数が最大文字数を超えていたら。
    if (bindingMessage.length() > MAX_BINDING_MESSAGE_LENGTH)
    {
        // 不正な (文字数が長すぎる) バインディングメッセージが含まれていた。
        // この場合、Authlete の /api/backchannel/authentication/fail API を呼び出して
        // 適切なレスポンスを生成し、例外としてスローする。
        // (この例外は post() メソッドにてキャッチされることに注意。)
        throw backchannelAuthenticationFail(baRes.getTicket(), Reason.INVALID_BINDING_MESSAGE);
    }
}

2.2.4. auth_req_id の発行

以上で必要な検証処理は完了したので、最後に Authlete の /api/backchannel/authentication/issue API を利用して auth_req_id を発行し、それをクライアントに返却します。

private Response handleUserIdentification(BackchannelAuthenticationResponse baRes)
{
    ...

    // auth_req_id を発行する。
    return issueAuthReqId(baRes);
}

private Response issueAuthReqId(BackchannelAuthenticationResponse baRes)
{
    // Authlete の /api/backchannel/authentication/issue API を呼び出す。これにより 'auth_req_id' が発行される。
    BackchannelAuthenticationIssueResponse baiRes = callBackchannelAuthenticationIssue(baRes.getTicket());

    // レスポンス内の action パラメーターは、「この認可サーバーが次にどのような処理を行うべきか」を示している。
    BackchannelAuthenticationIssueResponse.Action action = baiRes.getAction();

    // クライアントへ返却すべきレスポンスの内容。 (この内容は action パラメーターに依存して変わる。)
    String content = baiRes.getResponseContent();

    // action パラメーターに応じて処理を行う。
    switch (action)
    {
        case INTERNAL_SERVER_ERROR:
            // Authlete API の呼び出しが不適切であったか、Authlete サーバー側で何らかのエラーが発生した場合。
            // 500 Internal Server Error を返却。
            return ResponseUtil.internalServerError(content);

        case INVALID_TICKET:
            // API 呼び出し時に使った ticket が無効であった場合。
            // 500 Internal Server Error を返却。
            return ResponseUtil.internalServerError(content);

        case OK:
            // バックグラウンド処理を開始する。
            startCommunicationWithAuthenticationDevice(user, baRes);

            // 200 OK を返却 (content には 'auth_req_id' が含まれている)。
            return ResponseUtil.ok(content);

        default:
            // 上記以外の action の場合。これは起こり得ない。
            // 500 Internal Server Error を返却。
            return ResponseUtil.internalServerError("Unknown action returned from /api/backchannel/authentication/issue API.");
    }
}

action パラメーターの値が OK であった場合、認可サーバーは startCommunicationWithAuthenticationDevice() メソッドによりバックグラウンド処理を開始し (これについては、次セクションで取り扱います)、その後、クライアントにレスポンス (200 OK) を返却します。この時返却されるレスポンスの内容 (content パラメーター) には、Authlete によって発行された auth_req_id が含まれていることに注意してください。

2.2.5. バックグラウンド処理

上記で示したように、認可サーバーは auth_req_id をクライアントに返却する直前に、 startCommunicationWithAuthenticationDevice() メソッドによりバックグラウンド処理を開始します。2.1.1. バックチャネル認証エンドポイントの処理フローでも解説しましたが、今回の実装で認可サーバーは、バックグラウンド処理において以下の処理を行うことになります。

  1. エンドユーザーにクライアントを認可してもらうために、認証デバイスとのやりとりを行う
  2. やりとりが終わったら、 Authlete の /api/backchannel/authentication/complete API を呼び出す (= やりとりの結果を Authlete に伝える)
  3. /api/backchannel/authentication/complete API からのレスポンスに応じて適切な処理を行う

/api/backchannel/authentication/complete API から返却されるレスポンスの内容は、クライアントのバックチャネルトークンデリバリーモードに依存して変わることに注意してください。例えば、poll モードの場合、当該 API からは action=NO_ACTION 正常レスポンスが返却されますが、ping あるいは push モードの場合は、action=NOTIFICATION の正常レスポンスが返却されます。

また、action=NOTIFICATION のレスポンスが返却された場合、認可サーバーはクライアントの通知エンドポイントに対して通知を行う必要があるという点にも注意してください。

以上を踏まえ、以下にバックグラウンド処理の実装例を示します。

private void startCommunicationWithAuthenticationDevice(User user, BackchannelAuthenticationResponse info)
{
    // 認証デバイスとのやりとりを行なった後に Authlete の /api/backchannel/authentication/complete API 
    // を呼び出すためのチケット。
    final String ticket = info.getTicket();

    // クライアント名。
    final String clientName = info.getClientName();

    // クライアントによって要求されているスコープ群。
    final Scope[] scopes = info.getScopes();

    // クライアントによって要求されているクレイム群。
    final String[] claimNames = info.getClaimNames();

    // 認証デバイス上で表示されるバインディングメッセージ。  
    final String bindingMessage = info.getBindingMessage();

    // バッググラウンド処理を開始。
    Executors.newSingleThreadExecutor().execute(new Runnable() {
        try
        {
            // バックグラウンドにおける主要な処理。
            doInBackground(ticket, user, clientName, scopes, claimNames, bindingMessage);
        }
        catch (WebApplicationException e)
        {
            // エラーをロギング。
            Logger.log(e);
        }
        catch (Throwable t)
        {
            // エラーをロギング。
            Logger.log(t);
        }
    });
}

private void doInBackground(String ticket, User user, String clientName, Scope[] scopes, String[] claimNames, String bindingMessage)
{
    // 認証デバイスからのレスポンス。
    MyAuthenticationDeviceResponse response;

    try
    {
        // 認証デバイスとのやりとりを行う。
        response = communicateWithMyAuthenticationDevice(user.getSubject(), buildMessage());
    }
    catch (Throwable t)
    {
        // 認証デバイスとのやりとりにおいてエラーが発生。
        // この場合、Authlete の /api/backchannel/authentication/complete API を result=TRANSACTION_FAILED で呼び出す。
        completeWithTransactionFailed(ticket, user);
        return;
    }

    // 認証デバイス上でのエンドユーザーの判断結果 (= エンドユーザーがクライアントを認可したのかどうか)。
    MyAuthenticationDeviceResult result = response.getResult();

    if (result == null)
    {
        // result が空であった場合。これは起き得ない想定。
        // この場合、Authlete の /api/backchannel/authentication/complete API を result=TRANSACTION_FAILED で呼び出す。
        completeWithTransactionFailed(ticket, user);
        return;
    }

    switch (result)
    {
        case allow:
            // エンドユーザーがクライアントを認可した場合。
            // この場合、Authlete の /api/backchannel/authentication/complete API を result=AUTHORIZED で呼び出す。
            completeWithAuthorized(ticket, user, claimNames, new Date());      
            return;

        case deny:
            // エンドユーザーがクライアントを拒否した場合。
            // この場合、Authlete の /api/backchannel/authentication/complete API を result=ACCESS_DENIED で呼び出す。
            completeWithAccessDenied(ticket, user);      
            return;

        case timeout:
            // 認証デバイスでの処理中にタイムアウトが発生した場合。
            // この場合、Authlete の /api/backchannel/authentication/complete API を result=TRANSACTION_FAILED で呼び出す。           
            completeWithTransactionFailed(ticket, user);
            return;

        default:
            // 未知の result の場合。これは起き得ない想定。
            // この場合、Authlete の /api/backchannel/authentication/complete API を result=TRANSACTION_FAILED で呼び出す。     
            completeWithTransactionFailed(ticket, user);
            return;
    }
}

/**
 * Authlete の /api/backchannel/authentication/complete API を result=AUTHORIZED で呼び出す。
 */
private void completeWithAuthorized(String ticket, User user, String[] claimNames, Date authTime)
{
    complete(ticket, user, Result.AUTHORIZED, claimNames, authTime);
}

/**
 * Authlete の /api/backchannel/authentication/complete API を result=ACCESS_DENIED で呼び出す。
 */
private void completeWithAccessDenied(String ticket, User user)
{
    complete(ticket, user, Result.ACCESS_DENIED, null, null);
}

/**
 * Authlete の /api/backchannel/authentication/complete API を result=TRANSACTION_FAILED で呼び出す。
 */
private void completeWithTransactionFailed(String ticket, User user)
{
    complete(ticket, user, Result.TRANSACTION_FAILED, null, null);
}

/**
 * Authlete の /api/backchannel/authentication/complete API を呼び出して、認証デバイスとのやりとりの結果を Authlete に伝えるメソッド。
 */
private void complete(String ticket, User user, Result result, String[] claimNames, Date authTime)
{
    // エンドユーザーのサブジェクト。
    String subject = user.getUserSubject();

    // エンドユーザーが認証された時刻 (result == Result.AUTHORIZED の時のみ利用される) 。
    long userAuthenticatedAt = (result == Result.AUTHORIZED) ? authTime.getTime() / 1000L : 0;
      
    // エンドユーザーのクレイム群 (result == Result.AUTHORIZED の時のみ利用される)。
    Map<String, Object> claims = (result == Result.AUTHORIZED) ? collectClaims(user, claimNames) : null;

    // Authlete の /api/backchannel/authentication/complete API を呼び出す。
    BackchannelAuthenticationCompleteResponse response =
        callBackchannelAuthenticationComplete(ticket, subject, result, userAuthenticatedAt, claims);

    // レスポンス内の action パラメーターは、「この認可サーバーが次にどのような処理を行うべきか」を示している。
    BackchannelAuthenticationCompleteResponse.Action action = response.getAction();

    // action パラメーターに応じて処理を行う。
    switch (action)
    {
        case SERVER_ERROR:
            // Authlete API の呼び出しが不適切であったか、Authlete サーバー側で何らかのエラーが発生した場合。
            // 例外をスロー。
            throw new WebApplicationException( ResponseUtil.internalServerError(content) );

        case NO_ACTION:
            // 必要な処理がない場合。
            // これが起きるのは、クライアントのバックチャネルトークンデリバリーモードが poll モードの時のみ。
            return;

        case NOTIFICATION:
            // クライアントの notification エンドポイントに通知が必要な場合。
            // これが起きるのは、クライアントのバックチャネルトークンデリバリーモードが ping あるいは push モードの時のみ。
            // クライアントに通知を行う。
            handleNotification(response);
            return;

        default:
            // 上記以外の action の場合。これは起こり得ない。
            // 例外をスロー。
            throw new WebApplicationException(
                ResponseUtil.internalServerError("Unknown action returned from /api/backchannel/authentication/complete API.")
            );
    }
}

/**
 * クライアントの通知エンドポイントに通知を送信する。
 */
private void handleNotification(BackchannelAuthenticationCompleteResponse info)
{
    // クライアントの通知エンドポイントの URL。
    URI clientNotificationEndpointUri = info.getClientNotificationEndpoint();

    // クライアント通知トークン (Bearer トークンとして利用される)。
    String notificationToken = info.getClientNotificationToken();

    // クライアントに送信する通知の内容 (JSON)。
    String notificationContent = info.getResponseContent();

    // クライアントの通知エンドポイントからのレスポンス。
    Response response;

    try
    {
        // クライアントの通知エンドポイントに通知を送信。
        response = WEB_CLIENT.target(clientNotificationEndpointUri).request()
            .header(HttpHeaders.AUTHORIZATION, "Bearer " + notificationToken)
            .post(Entity.json(notificationContent));
    }
    catch (Throwable t)
    {
        // 通知の送信に失敗した場合。
        // 例外をスロー。
        throw new WebApplicationException(
            ResponseUtil.internalServerError("Failed to send the notification to the client", t)
        );
    }

    // クライアントの通知エンドポイントからのレスポンスに含まれる HTTP ステータスコード。
    Status status = Status.fromStatusCode(response.getStatusInfo().getStatusCode());

    // ステータスコードにしたがって、処理を行う。
    //
    // 注意: push モードにおいて error notification を通知する際に認可サーバーが
    // 通知エンドポイントからのレスポンスをどのように処理すべきなのかは、仕様書に規定されていない。
    // そのため、この実装では、push モードにおいて error notification を通知する場合でも、
    // 他の通知を行う場合と同様にレスポンスを処理するものとする。
  
    // ステータスコードが '200 OK' あるいは '204 No Content' であった場合。
    if (status == Status.OK || status == Status.NO_CONTENT)
    {
        // 以下の仕様に従い、リクエストは正常に処理されたものとみなす。
        //
        //   CIBA Core spec, 10.2. Ping Callback and 10.3. Push Callback
        //     For valid requests, the Client Notification Endpoint SHOULD
        //     respond with an HTTP 204 No Content.  The OP SHOULD also accept
        //     HTTP 200 OK and any body in the response SHOULD be ignored.
        //
        return;
    }

    // ステータスコードが '3xx' であった場合。
    if (status.getFamily() == Status.Family.REDIRECTION)
    {
        // 以下の仕様に従い、このケースは無視する。
        //
        //   CIBA Core spec, 10.2. Ping Callback, 10.3. Push Callback
        //     The Client MUST NOT return an HTTP 3xx code.  The OP MUST
        //     NOT follow redirects.
        //
        return;
    }    
}

2.3. トークンエンドポイントの実装

2.1.2. トークンエンドポイントの処理フローでも解説したように、Authlete を利用する場合、トークンエンドポイントの実装は非常にシンプルになりますOAuth 2.0 をサポートする場合と同様に1、認可サーバーはトークンエンドポイントに送られてきたリクエストの内容を Authlete の /api/auth/token API に渡し、当該 API からのレスポンスに含まれる action にしたがって適宜処理を行うだけです。

ただし、2.2. バックチャネル認証エンドポイントの実装でも述べたように、トークンエンドポイントでサポートするクライアント認証方式は、バックチャネル認証エンドポイントと揃える必要があります。今回の実装では、バックチャネル認証エンドポイントにおいて client_secret_basic (Basic 認証) のみをサポートしているので、トークンエンドポイントにおいても client_secret_basic のみをサポートすることになります。

以上を踏まえ、以下にトークンエンドポイントの実装例 (主要部分のみ) を示します。

@Path("/api/token")
public class TokenEndpoint
{
   ...
  
    @POST
    @Consumes(MediaType.APPLICATION_FORM_URLENCODED)
    public Response post(
            @HeaderParam(HttpHeaders.AUTHORIZATION) String authorization,
            MultivaluedMap<String, String> parameters)
    {  
        try
        {
            // 主要な処理。
            return doProcess(authorization, parameters);
        }
        catch (WebApplicationException e)
        {
            // 既知のエラー。
            return e.getResponse();
        }
        catch (Throwable t)
        {
            // 未知のエラー。
            return ResponseUtil.internalServerError("Unknown error occurred.");  
        }
    }

    private Response doProcess(String authorization, MultivaluedMap<String, String> parameters)
    {
        // Authorization ヘッダーに設定された値 (クライアントの認証情報) を取り出す。
        BasicCredentials credentials = BasicCredentials.parse(authorization);

        // クライアントの認証情報から「クライアント ID」と「クライアントシークレット」を取り出す。
        String clientId     = credentials == null ? null : credentials.getUserId();
        String clientSecret = credentials == null ? null : credentials.getPassword();

        // Authlete の /api/auth/token API を呼び出す。
        TokenResponse response = callToken(parameters, clientId, clientSecret);

        // レスポンス内の action パラメーターは、「この認可サーバーが次にどのような処理を行うべきか」を示している。
        Action action = response.getAction();

        // クライアントへ返却すべきレスポンスの内容。 (この内容は action パラメーターに依存して変わる。)      
        String content = response.getResponseContent();

        // action パラメーターに応じて処理を行う。
        switch (action)
        {
            case INVALID_CLIENT:
                // クライアント認証でエラーが生じた場合。
                // 401 Unauthorized を返却。
                return ResponseUtil.unauthorized(content, "Basic realm=\"token\"");

            case INTERNAL_SERVER_ERROR:
                // Authlete API の呼び出しが不適切であったか、Authlete サーバー側で何らかのエラーが発生した場合。
                // 500 Internal Server Error を返却。
                return ResponseUtil.internalServerError(content);

            case BAD_REQUEST:
                // トークンリクエストが不正であった場合。
                // 400 Bad Request を返却。
                return ResponseUtil.badRequest(content);

            case OK:
                // トークンリクエストが有効であった場合。
                // 200 OK を返却。
                return ResponseUtil.ok(content);

            case PASSWORD:
                // OAuth 2.0 の "Resource Owner Password Credentials" フローの場合。CIBA フローにおいてはこれは起き得ない。
                // 400 Bad Request を返却。
                return ResponseUtil.badRequest("PASSWORD action returned from /api/auth/token API but this authorization server does not allow Resource Owner Password Credentials flow.");

            default:
                // 上記以外の action の場合。これは起こり得ない。
                // 500 Internal Server Error を返却。
                return ResponseUtil.internalServerError("Unknown action returned from /api/auth/token API.");
        }      
    }

    ...
}

3. 動作確認

以上で CIBA フローに対応した認可サーバーを実装することができたので、今度は実際に CIBA フローを試してみましょう。以下で解説では、上記で実装した認可サーバーとして java-oauth-server を、認証デバイス・クライアントアプリとして CIBA シミュレーター上の AD シュミレーター・CD シミュレーターを利用するものとします。

3.1. 設定

動作確認を行う前に、必要となるコンポーネントを適宜設定しておきます。

3.1.1. Authlete の設定

3.1.1.1. サービスの設定

サービスオーナーコンソール上で、サービスの各項目を以下のように設定してください。

■ 「認可」タブ内

設定対象項目 設定内容
サポートする認可種別 CIBA にチェック

■ 「CIBA」タブ内

設定対象項目 設定内容
サポートするトークンデリバリーモード PING, POLL, PUSH の全て
ユーザーコードのサポート サポートする

3.1.1.2. クライアントの設定

デベロッパーコンソール上で、クライアントの各項目を以下のように設定してください。

■ 「基本情報」タブ内

設定対象項目 設定内容
クライアントタイプ CONFIDENTIAL

■ 「認可」タブ内

設定対象項目 設定内容
クライアント認証方式 CLIENT_SECRET_BASIC

■ 「CIBA」タブ内

設定対象項目 設定内容
トークンデリバリーモード 動作確認対象のデリバリーモードを選択
通知エンドポイント https://cibasim.authlete.com/api/notification
ユーザーコードの要求 要求する

3.1.2. CIBA シミュレーターの設定

3.1.2.1. プロジェクトの作成

CIBA シミュレーターのトップページ上で Namespace および Project のフィールドに任意の値を入力し、Create ボタンを押してください。
スクリーンショット 2019-03-01 14.20.56.png

プロジェクトが作成されると、以下のようにプロジェクトのトップページ (https://cibasim.authlete.com/{namespace}/{project}) に遷移します。プロジェクトのトップページからは、AD シミュレーターと CD シミュレーターの両者の設定ができるようになっています。
スクリーンショット 2019-03-01 14.40.30.png

3.1.2.2. AD シミュレーターの設定

エンドユーザーにクライアントの認可を仰ぐ際、デフォルトの java-oauth-server はシミュレーターの /api/authenticate/sync API に対して以下のようなリクエストを送信します。

{
  ...
  "user" : "{対象のエンドユーザーの識別子}",
  ...
}

すなわち、user フィールドには「認可を仰ぐ対象のユーザーを特定するための何らかの識別子」が必要になるわけですが、デフォルトの java-oauth-server の場合はこの識別子として「そのユーザーのサブジェクトの値」を利用しています。/api/authenticate/sync API が java-oauth-server からリクエストを受けた後、対象のエンドユーザーは AD シミュレーター上でクライアントを認可/拒否し、最終的にその判断結果が当該 API から java-oauth-server へと返却されます。

AD シミュレーターを起動するには、プロジェクトトップページの User ID のフィールドに対象のエンドユーザーの識別子 (=上記 user フィールドに埋め込まれる値) を入力し、Launch AD simulator ボタンを押します。デフォルトの java-oauth-server の場合、利用できるエンドユーザーは UserEntity.java に定義されているダミーユーザー群のみとなるため、今回はこれらのうち subject=1001 のユーザーを動作確認の対象とすることにしましょう。したがって User ID のフィールドには 1001 を入力します。
launch_ad.png

AD シミュレーターを起動すると、以下のようなページが表示されます。
スクリーンショット 2019-03-01 17.17.44.png

以下で行う動作確認の間は、常にこのページを立ち上げておく必要があるので注意してください。

3.1.2.3. CD シミュレーターの設定

プロジェクトのトップページで、各項目を以下のように設定し、最下にある Save ボタンを押して設定項目を保存してください。

設定対象項目 設定値
BC Authentication Endpoint URL 認可サーバーのバックチャネル認証エンドポイントの URL
Token Endpoint URL 認可サーバーのトークンエンドポイントの URL
Token Delivery Mode 動作確認対象のデリバリーモード
Client ID 上記クライアントのクライアント ID
Client Authentication Basic
Client Secret 上記クライアントのクライアントシークレット

スクリーンショット 2019-03-01 19.20.24.png

その後、Launch CD simulator ボタンを押して CD シミュレーターを起動してください。

スクリーンショット 2019-03-01 19.25.11.png

3.1.3. java-oauth-server の設定

java-oauth-server についてはこちらの記事で詳細に解説していますので、ここでは簡潔な説明に留めます。

java-oauth-server のソースコードをダウンロードし、以下のように authlete.properties を設定してください。

base_url = <Authlete API のベース URL>
service.api_key = <上記サービスの API キー>
service.api_secret = <上記サービスの API シークレット>

その後、以下のコマンドで java-oauth-server を起動してください。

mvn jetty:run -Dauthlete.ad.workspace=<CIBA シミュレーター上の namespace>/<CIBA シミュレーター上の project>

3.2. 各デリバリーモードの動作確認

各デリバリーモードの動作確認を行うにあたり、以下の項目を適宜設定するのを忘れないようにしてください。

  • デベロッパーコンソール上でのクライアントのトークンデリバリーモード
  • CD シミュレーター上の Token Delivery Mode

3.2.1. push モードの動作確認

  1. CD シミュレーターを起動して、以下のように各フィールドに値を入力してください。
設定対象項目 設定値
Scopes openid
ACR Values 任意の値
Hint の値 1001
Hint の種類 login_hint
Binding Message 任意の値
User Code 675325
スクリーンショット 2019-03-04 18.24.01.png
「Hint の値」「User Code」については、UserEntity.java に定義されている値を利用している点に注意してください。
  1. Send ボタンを押してバックチャネル認証リクエストを送信してください。
  2. バックチャネル認証リクエストが認可サーバー側で正常に処理されると auth_req_id が払い出され、CD シミュレーターに返却されます。
    スクリーンショット 2019-03-04 19.22.45のコピー2.png
  3. このタイミングで AD シミュレーター上では、以下のように認可画面が表示されています。
    スクリーンショット 2019-03-04 19.32.31.png
  4. Allow ボタンを押して、クライアントを認可します。
  5. 認可サーバーから CD シミュレーターに対して、アクセストークン等を含む正常レスポンスが push されます。
    スクリーンショット 2019-03-04 19.23.14.png

3.2.2. ping モードの動作確認

1、2、3 のステップは push モードの場合と同じです。認可サーバー側でバックチャネル認証リクエストが正常に処理されると、auth_req_id が払い出され、CD シミュレーターに返却されます。その後、CD シミュレーターは認可サーバーからクライアント通知エンドポイントへ通知が来るのを待ちます。
スクリーンショット 2019-03-05 15.00.43.png

AD シミュレーター上でクライアントを認可すると、認可サーバーからクライアント通知エンドポイントへ通知が送信され、その後、CD シミュレーター上はトークンエンドポイントへリクエストを送信します。トークンリクエストが正常に処理されると、以下のようにアクセストークン等を含むレスポンスが返却されます。
スクリーンショット 2019-03-05 15.01.03.png

3.2.3. poll モードの動作確認

1、2、3 のステップは push モードの場合と同じです。認可サーバー側でバックチャネル認証リクエストが正常に処理されると、auth_req_id が払い出され、CD シミュレーターに返却されます。その後、CD シミュレーターはトークンエンドポイントへポーリングを開始します。AD シミュレーター上でクライアントが認可されるまで、トークンエンドポイントからは authorization_pending のエラーが返却されている点に注目してください。
スクリーンショット 2019-03-05 15.19.59.png

AD シミュレーター上でクライアントを認可すると、トークンエンドポイントからはアクセストークン等を含む正常レスポンスが返却されます。
スクリーンショット 2019-03-05 15.21.12.png

  1. ここでは Resource Owner Password Credentials フローについては考慮していません。 2

  2. この場合においてもユーザーコードのチェックを行う実装もあり得るのですが、簡単のため、今回はそのような実装は行わないものとします。

16
10
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
16
10