OpenID Foundation Japan

Java Servletで実装したOpenID Connect Relying Party

Relying Partyの実装方法には、OpenID ConnectとSCIMのエンタープライズ実装ガイドライン の付録C.5にあるように Apacheのmod_auth_openidcを使用した方法がありますが、ここではJava Servletとしてスクラッチで実装する方法について記述します。

作成に当たってはGoogle社が提供しているOAuthのJavaライブラリを使用しています。

このソースコードの中ではライブラリを、主にIdPへのリダイレクトURLおよびリクエストの組み立て、IDトークンの検証に使用しています。

IdPは、 OpenAM を使用します。

  1. 使用するソースコードについて この章で使用するソースコードは https://github.com/Kenji-Tsuchimochi/oidcrpsampleam で公開しています。使用する際は、OIDCConstsで定義されている値を変更して下さい。

  2. ライブラリの入手方法について Webブラウザで https://developers.google.com/api-client-library/java/google-oauth-java-client/download を表示し、google-oauth-java-client-featured.zip のリンクをクリックして下さい。

  3. 各クラスの役割について 各クラスの役割は下記の通りです

クラス名 役割
OIDCIndex Relying Partyのトップページ。stateとnonceをセッションに設定し、「〇〇でログイン」のリンクを出力する
OIDCStart Relying Partyの認証開始ページ。IdPのログインページにリダイレクトする
OIDCCallback エンドユーザの認可が正常終了した場合にIdPからコールバックされるページ。
1. アクセストークンの取得
2. IdTokenの検証
3. UserInfoAPIの実行
を行う
OIDCUtil ユーティリティクラス。at_hash検証用の文字列の作成、公開鍵の取得、UserInfoAPIの呼び出しを行う
OIDCConsts 定数を管理するクラス
OIDCJwk JWK形式で定義されているIdPの公開鍵を表現するクラス

各クラスのメソッドの解説は下記の通りです

OIDCIndex#doGetメソッド

stateおよびnonceの設定行います。両者ともエンドユーザ毎に、容易に推測できず重複しない値を設定して下さい。

sess.setAttribute("state", UUID.randomUUID().toString());
sess.setAttribute("nonce", UUID.randomUUID().toString());

認可コードフローを開始するためのリンクを出力します

PrintWriter pw = new PrintWriter(res.getOutputStream());
pw.println("<!DOCTYPE html>");
pw.println("<html lang=\"ja\">");
pw.println("<head>");
pw.println("<title>OpenID Connect Relying Partyサンプル</title>");
pw.println("</head>");
pw.println("<body>");
pw.println("<a href=\"" + req.getContextPath() + "/start" + "\">OpenAM連携 で認証</a>");
pw.println("</body>");
pw.println("</html>");
pw.flush();
pw.close();

ブラウザでの出力は下記の通りです

OIDCIndex出力結果

OIDCStart#doGetメソッド

stateおよびnonceがセッションに格納されているかチェックしています。

HttpSession sess = req.getSession();
if(sess == null || sess.getAttribute("state") == null || sess.getAttribute("nonce") == null) {
    res.sendRedirect("/");
}
else {
    String state = sess.getAttribute("state").toString();
    String nonce = sess.getAttribute("nonce").toString();

    if(state.isEmpty()) {
        res.sendRedirect("/");
    }
    else if(nonce.isEmpty()) {
        res.sendRedirect("/");
    }
(以下省略)

認可コードリクエストに必要なパラメータを設定します

//response_typeパラメータを設定
url.setResponseTypes(Arrays.asList("code"));

//scopeパラメータを設定
url.setScopes(Arrays.asList("openid","profile"));

//stateパラメータを設定
url.setState(state);

//nonceパラメータを設定
url.set("nonce", nonce);

//redirect_uriパラメータを設定
GenericUrl redirectUri = new GenericUrl("http://localhost:8080" + req.getContextPath());
redirectUri.appendRawPath(OIDCConsts.REDIRECT_URI);
url.setRedirectUri(redirectUri.build());

認可コードサーバにリダイレクトします


res.sendRedirect(url.build());

OIDCUtil#getAtHashメソッド

IDトークン検証時のアクセストークン置き換え攻撃の検知のためのトークンのハッシュ値(at_hash)を取得するメソッドです。

IdPより取得したアクセストークンをIDトークンのヘッダ部分に含まれるalg値に対応する関数でハッシュ化し、その前半分をBase64URLエンコードしたものを返却します。下記の場合はSHA256を使用しています。

public static String getAtHash(String accessToken) {
    byte[] hashedbytes = DigestUtils.sha256(accessToken);
    byte[] hashedbyteshalf = new byte[hashedbytes.length/2];
    System.arraycopy(hashedbytes, 0, hashedbyteshalf, 0, hashedbyteshalf.length);
    return Base64.encodeBase64URLSafeString(hashedbyteshalf);
}

OIDCUtil#getJwkPublicKeyメソッド

JWKを公開しているエンドポイントにアクセスし、IDトークン検証用の公開鍵を取得します。

公開鍵を別の方法で取得しているのであればこの部分は必要ありません。

public static PublicKey getJwkPublicKey(String kid) throws IOException, NoSuchAlgorithmException, InvalidKeySpecException {
    //Public Key エンドポイントにアクセス
    URL url = new URL(OIDCConsts.PUBKEY_URL);
    HttpsURLConnection conn = (HttpsURLConnection)url.openConnection();

    if(conn.getResponseCode() == HttpsURLConnection.HTTP_OK) {
        //JSON形式で返却される値をパースする
        JsonParser parser = new JsonParser();
        JsonObject obj = parser.parse(new InputStreamReader(conn.getInputStream())).getAsJsonObject();

        //Jwk形式の値から公開鍵を生成する
        OIDCJwk jwk = new OIDCJwk(obj);
        PublicKey pubKey = jwk.getKey(jwk.getKidArray()[0]);
        return pubKey;
    }
    else {
        return null;
    }
}

OIDCUtil#getUserInfoメソッド

OpenAMの属性取得APIにアクセスするメソッド。下記コメントにある通り、Authorizationヘッダに、Bearerトークン形式でアクセストークンを設定する必要があります。

public static String getUserInfo(String accessToken) throws IOException {
    URL url = new URL(OIDCConsts.USER_INFO_API_URL);
    HttpsURLConnection conn = (HttpsURLConnection)url.openConnection();

    //Authorizationヘッダに、Bearerトークン形式でアクセストークンを設定する
    conn.setRequestProperty("Authorization", "Bearer " + accessToken);

    if(conn.getResponseCode() == HttpsURLConnection.HTTP_OK) {
        BufferedReader br = new BufferedReader(new InputStreamReader(conn.getInputStream()));
        StringBuilder sb = new StringBuilder();
        String line = null;
        while((line = br.readLine()) != null) {
            sb.append(line);
        }

        return sb.toString();
    }
    else {
        return null;
    }
}

OIDCCallback#doGetメソッド

まず、セッションのstate値とリクエストパラメータのstate値が一致していることを確認します。

nonceについても後の検証のために取得しておきます

String state = String.valueOf(sess.getAttribute("state"));
String nonce = String.valueOf(sess.getAttribute("nonce"));

//セッションに入っているstateとリクエストパラメータで渡ってくるstateが一致していることを確認
if( ! state.equals(req.getParameter("state"))) {
    res.sendError(HttpServletResponse.SC_FORBIDDEN,"Invalid state");
    return;
}

次に認可コードを取得します

//認可コードがリクエストパラメータに存在していることを確認
String code = req.getParameter("code");
if(code == null || code.isEmpty()) {
    res.sendError(HttpServletResponse.SC_FORBIDDEN,"Invalid code");
    return;
}

トークンエンドポイントへアクセスし、アクセストークンとIDトークンを取得します

//トークンエンドポイントへのリクエストを生成する
AuthorizationCodeTokenRequest authreq = new AuthorizationCodeTokenRequest(
        new NetHttpTransport()
        , new JacksonFactory()
        , new GenericUrl(OIDCConsts.TOKEN_URL)
        , code
);
authreq.setRedirectUri(OIDCConsts.REDIRECT_SERVER + req.getContextPath() + OIDCConsts.REDIRECT_URI)
.setClientAuthentication(
    new BasicAuthentication( OIDCConsts.CLIENT_ID, OIDCConsts.CLIENT_SECRET )
);

//トークンエンドポイントへリクエストを送出する
HttpResponse httpres =  authreq.executeUnparsed();

//レスポンスを取得し、パースする
IdTokenResponse idtokenres = httpres.parseAs(IdTokenResponse.class);

//レスポンスに含まれるアクセストークンを取得する
String accessToken = idtokenres.getAccessToken();

//IDトークンを取得する
IdToken idToken = IdToken.parse(idtokenres.getFactory(), idtokenres.getIdToken());

IDトークンを検証します。OpenID Connect Core仕様書に記述されている手順で検証を行います。

//IDトークンの署名を検証する
if( ! idToken.verifySignature(OIDCUtil.getYConnectPublicKey(idToken.getHeader().getKeyId()))) {
    res.sendError(HttpServletResponse.SC_FORBIDDEN,"Invalid signature");
    return;
}
//iss値の検証
if( ! idToken.verifyIssuer(Arrays.asList(OIDCConsts.ISSUER_URL))) {
    res.sendError(HttpServletResponse.SC_FORBIDDEN,"Invalid issuer");
    return;
}
//aud値の検証
if( ! idToken.verifyAudience(Arrays.asList(OIDCConsts.CLIENT_ID))) {
    res.sendError(HttpServletResponse.SC_FORBIDDEN,"Invalid audience");
    return;
}
//セッションに入っているnonceとIDトークンに入っているnonceが一致していることを検証
if( ! nonce.equals(idToken.getPayload().getNonce())) {
    res.sendError(HttpServletResponse.SC_FORBIDDEN,"Invalid nonce");
    return;
}
//at_hathの検証
if( ! OIDCUtil.getAtHash(accessToken).equals(idToken.getPayload().getAccessTokenHash())) {
    res.sendError(HttpServletResponse.SC_FORBIDDEN,"Invalid at_hash");
    return;
}
//exp値の検証
if( ! idToken.verifyExpirationTime(System.currentTimeMillis(),0)) {
    res.sendError(HttpServletResponse.SC_FORBIDDEN,"Invalid Expiration Time");
    return;
}
//iat値の検証
if( ! idToken.verifyIssuedAtTime(System.currentTimeMillis(), 600)) {
    res.sendError(HttpServletResponse.SC_FORBIDDEN,"Invalid Issued At");
    return;
}

最後にUserInfoAPIにアクセスし、結果を出力します

//UserInfoAPIにアクセスし、結果を出力
String userInfoJsonStr = OIDCUtil.getUserInfo(accessToken);
res.setContentType("text/plain");
res.setCharacterEncoding("UTF-8");
PrintWriter pw = new PrintWriter(res.getOutputStream());
pw.println(userInfoJsonStr);
pw.flush();
pw.close();

ブラウザでの出力は下記の通りです

OIDCCallback出力結果

以上