Quarkus Sample – OpenID Connect でセキュアなエンドポイントを構築する

2021.03.18

はじめに

個人的に CloudNative なアプリケーションフレームワーク Quarkus のいろいろなモジュールを試しています。その中から今回は OpenID Connect クライアントモジュールを利用してみます。

QUARKUS - USING OPENID CONNECT TO PROTECT WEB APPLICATIONS USING AUTHORIZATION CODE FLOW

例では KeyCloak を利用していましたが、今回は Auth0 を利用しました。

事前準備

Quarkus ブランクプロジェクトの作成

Quarkus の最低限のブランクプロジェクトを作成します。追加の依存は何も指定しません。

pom.xml の中は以下の通り。

<dependencies>
  <dependency>
    <groupId>io.quarkus</groupId>
    <artifactId>quarkus-arc</artifactId>
  </dependency>
  <dependency>
    <groupId>io.quarkus</groupId>
    <artifactId>quarkus-resteasy</artifactId>
  </dependency>
  <dependency>
    <groupId>io.quarkus</groupId>
    <artifactId>quarkus-junit5</artifactId>
    <scope>test</scope>
  </dependency>
  <dependency>
    <groupId>io.rest-assured</groupId>
    <artifactId>rest-assured</artifactId>
    <scope>test</scope>
  </dependency>
</dependencies>

デフォルトで /hello-resteasy エンドポイントや index.html が配置されており、テストコードもあります。このままでも普通に動作します。

$ ./mvnw compile quarkus:dev
--2021-01-28 00:51:13--  https://repo.maven.apache.org/maven2/io/takari/maven-wrapper/0.5.6/maven-wrapper-0.5.6.jar
Resolving repo.maven.apache.org (repo.maven.apache.org)... 151.101.40.215
Connecting to repo.maven.apache.org (repo.maven.apache.org)|151.101.40.215|:443... connected.
HTTP request sent, awaiting response... 200 OK
...
Listening for transport dt_socket at address: 5005
__  ____  __  _____   ___  __ ____  ______ 
 --/ __ \/ / / / _ | / _ \/ //_/ / / / __/ 
 -/ /_/ / /_/ / __ |/ , _/ ,< / /_/ /\ \   
--\___\_\____/_/ |_/_/|_/_/|_|\____/___/   
2021-01-28 00:56:08,979 INFO  [io.quarkus] (Quarkus Main Thread) quarkus-oidc-sample 1.0.0-SNAPSHOT on JVM (powered by Quarkus 1.11.0.Final) started in 1.242s. Listening on: http://localhost:8080
2021-01-28 00:56:08,982 INFO  [io.quarkus] (Quarkus Main Thread) Profile dev activated. Live Coding activated.
2021-01-28 00:56:08,982 INFO  [io.quarkus] (Quarkus Main Thread) Installed features: [cdi, resteasy]

/hello-resteasy エンドポイントを確認。

$ curl http://localhost:8080/hello-resteasy
Hello RESTEasy

はい。

ブラウザで index.html を確認。

はい。

Auth0 Web Application 作成

Auth0 を利用します。前提としてすでにアカウントサインアップは完了している状態から開始します。

通常の Web Application を作成します。

Settings 以下の Domain, ClientId, Secret を利用します。

Social Login 設定

Social Login でログインしたい場合、 Social Connection の設定が必要です。今回は Google の Social Login を利用して Google の Profile 情報を読みたいのでこちらの設定を行います。

  1. Connections > Social を選択し、 CREATE SOCIAL CONNECTION をクリック。
  2. Google を選択。
  3. Settings はひとまずデフォルト状態で Connection を作成。
  4. 対象の Application に対して有効に

これで Auth0 のログインフォームで Google の Social Login が利用できるように。

Redirect URL の設定

Auth0 の認証処理を経てアプリケーションへ戻る際に、Callback URL を指定します。これはホワイトリストで Redirect される可能性のある URL をすべて列挙する必要があります。今回は、 http://localhost:8080/tokens が Callback 先なのでこちらを登録します。

OIDC モジュールの追加

pom.xml に OIDC モジュールを追加します。

<dependency>
  <groupId>io.quarkus</groupId>
  <artifactId>quarkus-oidc</artifactId>
</dependency>

ちなみにモジュールを入れた直後に何もせずに起動すると失敗します。

Listening for transport dt_socket at address: 5005
Both 'auth-server-url' and 'client-id' properties must be configured
Quarkus application exited with code 1
Press Enter to restart or Ctrl + C to quit
__  ____  __  _____   ___  __ ____  ______ 
 --/ __ \/ / / / _ | / _ \/ //_/ / / / __/ 
 -/ /_/ / /_/ / __ |/ , _/ ,< / /_/ /\ \   
--\___\_\____/_/ |_/_/|_/_/|_|\____/___/   
2021-01-28 00:59:12,430 INFO  [io.qua.dep.dev.IsolatedDevModeMain] (main) Attempting to start hot replacement endpoint to recover from previous Quarkus startup failure

Both 'auth-server-url' and 'client-id' properties must be configured 最低限この2つの設定は必須だからな!と怒られました。すみません。

application.properties

OIDC モジュールで必要な設定値を埋めていきます。先程のログからわかるように最低限の設定は auth-server-urlclient-id の模様。他は Optional な値であったり、 Default 値が適用される設定値のようです。

Tutorial の方ではベタ書きしているのですが、Secret 情報をベタ書きしてしまうと、意図せず Github へアップロードしたりする可能性があるため環境変数から取得するようにします。

quarkus.oidc.auth-server-url=${AUTH_SERVER_URL}
quarkus.oidc.client-id=${CLIENT_ID}
quarkus.oidc.credentials.secret=${CLIENT_SECRET}
quarkus.oidc.application-type=web-app
quarkus.oidc.authentication.scopes=openid,profile

プロパティの詳細な説明はこちらです。それぞれの項目については以下の通り。

auth-server-url

今回は Auth0 のドメインを指定します。 https://<YOUR_DOMAIN>.auth0.com といった値です。 Auth0 のアプリケーション設定値では <YOUR_DOMAIN>.auth0.com  が表示されているのでスキーマを追加して設定します。

client-id

ClientId を指定します。この値は Application ごとに異なります。

Credentials.secret

Client Secret の値を指定します。Secret の値は重要なため Auth0 の設定画面でもマスクされた状態です。

application-type

Quarkus で構成するアプリケーションがどのようなタイプなのかを指定します。

API サーバーとして構成したい場合は、未認証の場合のログインフォームへのリダイレクトは不要なので service を指定します。これを指定すると Authorization ヘッダに有効な Bearer Token が付与されていない場合は、 401 Unauthorize エラーが返却されます。利用者は自身で有効な Bearer Token を取得し、Authorization ヘッダに自身で付与する必要があります。

今回はユーザーへ画面を提供する Web アプリケーションとして構成するため web-app を指定します。これにより認証が済んでいないユーザーは Auth0 が提供するログインフォームへリダイレクトされます。

Default は service が設定されるので明示的に指定する必要があります。

authentication.scopes

OpenID Connect を指定するため openid の指定は必須。名前等のプロフィール情報もほしいので profile を指定します。

tokens エンドポイント

保護されるエンドポイントを作成します。このエンドポイントにアクセスする場合は、認可されていないとアクセスできません。 Social Login で取得できた IdToken, AccessToken, RefreshToken をダンプする HTML を作成します。

@Path("/tokens")
public class TokenResource {

    @Inject
    @IdToken
    JsonWebToken idToken;

    @Inject
    AccessTokenCredential accessToken; // ※1

    @Inject
    RefreshToken refreshToken;

    @GET
    public String getTokens() {
        var response = new StringBuilder().append("<html>")
        .append("<body>")
        .append("<ul>");

        if (idToken != null) {
            response.append("<li>IdToken:").append(idToken.toString()).append("</li>");
        }

        if (accessToken != null) { // ※2
            response.append("<li>AccessToken:").append(accessToken.getToken()).append("</li>");
        }

        var userName = this.idToken.getClaim("preferred_username");

        if (userName != null) {
            response.append("<li>username:").append(userName.toString()).append("</li>");
        }

//        var scopes = this.accessToken.getClaim("scope"); ※3

        response.append("<li>refresh_token:").append(refreshToken.getToken() != null).append("</li>");
        return response.append("</ul>").append("</body>").append("</html>").toString();
    }
}

ガイドに記載されているコードから一部修正を行っています。理由は後述します。

アプリケーションを確認

事前準備

application.properties で環境変数から値を取得するようにしているため、環境変数を設定します。

export AUTH_SERVER_URL=https://<YOUR_DOMAIN>.auth0.com
export CLIENT_ID=<AUTH0_APPLICATION_CLIENT_ID>
export CLIENT_SECRET=<AUTH0_APPLICATION_CLIENT_SECRET>

それぞれ Auth0 のアプリケーション設定からコピペして設定してください。

実行する

$ ./mvnw clean compile quarkus:dev
...
[INFO] --- quarkus-maven-plugin:1.11.0.Final:dev (default-cli) @ quarkus-oidc-sample ---
Listening for transport dt_socket at address: 5005
__  ____  __  _____   ___  __ ____  ______ 
 --/ __ \/ / / / _ | / _ \/ //_/ / / / __/ 
 -/ /_/ / /_/ / __ |/ , _/ ,< / /_/ /\ \   
--\___\_\____/_/ |_/_/|_/_/|_|\____/___/   
2021-02-04 16:38:46,578 INFO  [io.quarkus] (Quarkus Main Thread) quarkus-oidc-sample 1.0.0-SNAPSHOT on JVM (powered by Quarkus 1.11.0.Final) started in 3.019s. Listening on: http://localhost:8080
2021-02-04 16:38:46,581 INFO  [io.quarkus] (Quarkus Main Thread) Profile dev activated. Live Coding activated.
2021-02-04 16:38:46,581 INFO  [io.quarkus] (Quarkus Main Thread) Installed features: [cdi, oidc, resteasy, security]

動作を確認

  1. http://localhost:8080/tokens へアクセス
  2. Auth0 のログインフォームへ Redirect
  3. Google アカウントで Social Login
  4. Auth0 へ対象データの読み取りを許可。
  5. http://localhost:8080/tokens へRedirect され、結果を表示。
<html>
  <body>
    <ul>
      <li>IdToken:DefaultJWTCallerPrincipal{id='null', name='google-oauth2|XXXXXXXXXXXX', expiration=1612460836, notBefore=0, issuedAt=1612424836, issuer='https://xxxxxx.auth0.com/', audience=[39YRjuE4U3bCJm85emPkTbwhJTVvGLZF], subject='google-oauth2|XXXXXXXXXXXX', type='JWT', issuedFor='null', authTime=0, givenName='HIRAKU', familyName='KOMURO', middleName='null', nickName='hogehoge', preferredUsername='null', email='null', emailVerified=null, allowedOrigins=null, updatedAt=null, acr='null', groups=]}</li>
      <li>AccessToken:izvFojU3g_aIAirlkxxxxxxxxXXXXXX</li>
      <li>refresh_token:true</li>
    </ul>
  </body>
</html>

IdToken の全体情報、AccessToken 情報、RefreshToken の有無を表示しています。Google の Profile 情報から取得しているのでわたしの本名やニックネームといった情報が含まれているのが確認できます。 email は scope で指定していないので取れていません。想定通り。

TokenResource のコード上では Profile の中の prefered_username を探しているようですが、今回は null なので値なし。

サンプルコードの修正箇所について

何箇所かそのままでは利用できなかったので、一部修正しています。元々 KeyCloak を対象にしたサンプルコードであるため、違いが出ているようです。

標準があるとはいえ、若干の違いが出る関係上、少々調整が必要なようです。

※1

ガイドでは以下でコードが記載されています。

@Inject
JsonWebToken accessToken;

しかしサーバーを起動後、ログインフォームにて Google の Social Login を実行すると以下のエラーが発生し Failed します。

2021-02-03 22:15:49,664 ERROR [io.qua.ver.htt.run.QuarkusErrorHandler] (executor-thread-1) HTTP Request to /tokens failed, error id: 69786bf6-a838-42c0-95ac-f411940ef88f-1: org.jboss.resteasy.spi.UnhandledException: io.quarkus.oidc.OIDCException: Opaque access token can not be converted to JsonWebToken
        at org.jboss.resteasy.core.ExceptionHandler.handleApplicationException(ExceptionHandler.java:106)
  ...

OpaqueTokenJsonWebToken にしようとして失敗してます。どうやら正常にパースできてなさそう。

そこで、AccessTokenCredential に変更して対応しています。

これが正しい対応かは不明です。同じパッケージ内にあった最も Opaque Token をハンドリングできそうなクラスを適用しました。

Auth0 に AccessToken に関するセクションがありました。こちらを確認すると Opaque AccessTokenJWT AccessToken があるのが確認できます。今回は OpaqueToken であるため、構造化されたデータではなく単なる文字列が返ってきています。

Auth0 Docs - Access Tokens

※2

前段で accessTokenAccessTokenCredential に変更しました。ここでは Token の中身を出力するようにダンプしました。 AccessTokenCredential には getToken() メソッドが存在し、トークンの中身をStringで取り出せるようです。

※3

元のコードでは JsonWebToken で構造化された Token を期待していたため、中から scope にあたる Claim を取得しようとしています。しかし今回 accessToken は JWT ではないため、この scope は取得できません。従ってコードを削除しています。

Scopes

今回は openidprofile を scope に設定しました。しかしこの設定値は Required になっていません。そのため、明示的に設定しなくても動くはずです。

何も指定しない場合のデフォルト値

コードを確認してみると、scopes のデフォルト値は空のようでした。

extensions/oidc/runtime/src/main/java/io/quarkus/oidc/OidcTenantConfig.java

/**
 * List of scopes
 */
@ConfigItem
public Optional<List<String>> scopes = Optional.empty();

OpenID Connect を利用する場合 openid の指定が必須のはず。

Quarkus の設定では Optional になっており、 Default 値は明記されていませんでした。Auth0 のドキュメントでは required とされているので、おそらく指定はされている気がするのですが。

今度深追いしてみたい。どこかで設定されてそう。

OpenID Connect Scopes

おまけ

Auth0 の IdToken に含まれる updated_at のパースに失敗しており、 Quarkus の中で参照する IdToken の Claim に情報を適用できていません。従って、 updated_at は常に null です。

以下 StackTrace です。

2021-01-19 23:40:55,174 WARN  [io.sma.jwt.aut.principal] (executor-thread-1) SRJWT08002: getClaimValue failure for: updated_at: org.jose4j.jwt.MalformedClaimException: The value of the 'updated_at' claim is not the expected type (2021-01-17T07:27:40.906Z - Cannot cast java.lang.String to java.lang.Long)
        at org.jose4j.jwt.JwtClaims.getClaimValue(JwtClaims.java:256)
...

どうやら String から Long への Cast に失敗しているようです。updated_at の値は Auth0 から ISO 8601 形式の日時が送られてきてる模様。"1615993934" といった文字列を期待してパースを行っている模様。

OpenID Connect Core 1.0 incorporating errata set 1 - 5.1. Standard Claims では updated_at は Epoch Sec を number で返すよう定義してあるため、おそらくQuarkus 側のパーサーはこれを期待していそうです。

Auth0 の Community に随分昔から該当の Issue はあるようです。特に修正予定はなさそう。

Auth0 Community - OIDC ID Token claim updated_at violates OIDC specification, breaks RP implementations

今後の宿題

  • HTML テンプレートを使いたい
  • Json でログを出力したい
  • Docker イメージをビルドしてコンテナで実行したい
  • Id Token のカスタムパーサーが書けるかどうか試したい
  • Quarkus の OIDC モジュール使って何も指定しない際の scope 指定はどうなってるか

参照