OpenID Connect でパスワードレス認証を使う

| 9 min read
Author: shigeki-shoji shigeki-shojiの画像

庄司です。

WebAuthn でパスワードの無い世界へ」に続く「Envoy Proxy による HTTPS Proxy」の記事でプライベートネット内にパスワードレス認証ができる環境構築の方法を説明しました。この記事では、OpenID Connect の Code Flow を使ってパスワードレス認証を説明します。

サービスの概要

#

題材は宇宙船の冬眠ポッド (hibernation pod) です。冬眠ポッド毎にログインする URL は違いますが間違いやすいため、認証画面にアクセスする QRコード[1] を冬眠ポッドのディスプレイに表示しています。

iPhone 等のカメラを通して QRコード のリンクをブラウザで開くと Keycloak の認証画面が表示されます。認証されると、iPhone 等のブラウザに、OpenID Connect の ID トークンとポッド ID が表示されます。

Keycloak の設定

#
Information

Kubernetes 環境で動作する「Apple Touch ID Keyboard を使ったパスワードレス認証」に書いた手順を使った場合も設定は失われません。
この手順を使用して構成した場合は「クライアントの作成」までスキップしてください。

前回の記事「Envoy Proxy による HTTPS Proxy」では説明しませんでしが、Keycloak はデフォルトで h2 データベースを使用します。データが保存されるディレクトリは、コンテナ内の /opt/keycloak/data です。このディレクトリにローカルストレージを割り当てることで保持するデータの永続化が可能になります。docker-compose.yml ファイルは次の通りです。

version: "3"
services:
  envoy:
    image: envoyproxy/envoy:v1.21-latest
    volumes:
      - ./front-envoy.yaml:/etc/front-envoy.yaml
      - ./certs:/etc/envoy/certs
    ports:
      - 443:443
      - 9901:9901
    command: ["-c", "/etc/front-envoy.yaml", "--service-cluster", "front-proxy"]
  keycloak:
    image: quay.io/keycloak/keycloak
    volumes:
      - ./datadir:/opt/keycloak/data
    command:
      - start
      - --proxy=edge
      - --hostname-strict=false
      - --http-relative-path
      - auth
    environment:
      KEYCLOAK_ADMIN: admin
      KEYCLOAK_ADMIN_PASSWORD: password
      PROXY_ADDRESS_FORWARDING: true

パスワードレス認証のレルム設定

#

パスワードレスの認証を有効にする手順は、前の記事「WebAuthn でパスワードの無い世界へ」を参照してください。

クライアントの作成

#

サービスが使用するクライアントを作成します。「Clients」をクリックして、「Create」ボタンをクリックします。

Client ID に hibernation-pod と入力して「Save」ボタンをクリックします。

Settings

#
  • Access Typepublic から confidential に変更します。
  • Implicit Flow Enabled は、最初は ON にしておくとテストしやすいと思います。この記事では OFF のままで問題ありません。
  • Valid Redirect URIs は、http://localhost:8080/example/callback と PC のホスト名、例えば mymac という名前であれば http://mymac.local:8080/example/callback を設定します。

入力したら「Save」ボタンをクリックします。

Credentials

#

Credentials のタブを選択します。

表示された Secret の値を記録しておいてください。サービスの設定ファイルで使用します。

Mappers

#

ID トークン等の JWT のペイロードには独自の属性を追加できます。この記事で構築しようとしている認証サービスは、宇宙船の冬眠ポッドが開いた時にコマンドを実行する別の記事「第2回 イベントストーミングとドメイン駆動設計の戦略的設計」の要素の1つです。そのため、custom:firstnamecustom:type を ID トークンに含められるようマッピングを作成します。

「Mappers」タブを選択して、「Create」ボタンをクリックします。

  • Name フィールドに First Name と入力します。
  • Mapper TypeUser Attribute を選択します。
  • User Attributefirstname と入力します。
  • Token Claim Namecustom:firstname と入力します。
  • Claim JSON TypeString を選択します。

「Save」ボタンをクリックします。

custom:type も同じ要領で次のように入力して「Save」ボタンをクリックします。

  • Name フィールドに Type と入力します。
  • Mapper TypeUser Attribute を選択します。
  • User Attributetype と入力します。
  • Token Claim Namecustom:type と入力します。
  • Claim JSON TypeString を選択します。
Information

トークンの Claim Name を custom:typecustom:firstname としているのは、Amazon Cognito ユーザープール に置き換える可能性を考慮したためです。Amazon Cognito ユーザープールのカスタム属性は、custom: がプレフィックスとして付加されます。

Keycloak で OpenID Connect を有効にするための設定は以上です。

サービスのコード

#

ここからはサービスのコードを見ていくことにします。

インデックスページ

#

インデックスページの OpenAPI 定義は次の部分です。

  /index:
    get:
      description: Login Url
      parameters:
        - name: podId
          in: query
          description: Identity of a hibernation pod
          required: true
          schema:
            type: string
      responses:
        '200':
          description: OK
          content:
            image/png:
              schema:
                type: string
                format: binary
      tags:
        - auth

/index?podId=id-001 等のポッド IDを付加したリクエストを受けて、QR コードの PNG イメージをレスポンスします。

ログインページ

#

ログインページを定義した部分は次の通りです。

  /login:
    get:
      description: Redirect to Authorization Endpoint
      parameters:
        - name: podId
          in: query
          description: Identity of a hibernation pod
          required: true
          schema:
            type: string
      responses:
        '302':
          description: FOUND
      tags:
        - auth

この URL のレスポンスは、state を採番しハッシュ化して Cookie に設定し、またポッド ID も Cookie に設定して、Keycloak の Authorization Endpoint にリダイレクトをレスポンスします。

OpenID Connect の Code Flow で state は次のように説明されています。

RECOMMENDED. Opaque value used to maintain state between the request and the callback. Typically, Cross-Site Request Forgery (CSRF, XSRF) mitigation is done by cryptographically binding the value of this parameter with a browser cookie.

推奨。リクエストとコールバックの間で維持される不透明な値。通常、Cross-Site Request Forgery (CSRF、XSRF) の軽減はこのパラメータの値を暗号化してブラウザ Cookie にバインドすることによって実行します。

ログインページのコードは、AuthApiController.java を参照してください。

コールバック

#

Code Flow は認証が成功した時、短時間有効な code がレスポンスされます。この code を使って Keycloak のトークンエンドポイントにアクセスして ID トークンを取得できます。

コールバックを実装したインフラストラクチャー層の CodeFlow.java は次の通りです。

package com.mamezou_tech.example.infrastructure.oidc;

import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.io.IOException;
import java.net.URI;
import java.net.URISyntaxException;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.util.Base64;
import java.util.Map;

public class CodeFlow {

    private static final Logger logger = LoggerFactory.getLogger(CodeFlow.class);

    private final URI tokenEndpoint;

    private final String clientId;

    private final String clientSecret;

    private final ObjectMapper mapper;

    public CodeFlow(String tokenEndpoint, String clientId, String clientSecret) throws URISyntaxException {
        this.mapper = new ObjectMapper();
        this.tokenEndpoint = new URI(tokenEndpoint);
        this.clientId = clientId;
        this.clientSecret = clientSecret;
    }

    private String clientSecretBasic() {
        return Base64.getUrlEncoder().encodeToString((clientId + ":" + clientSecret).getBytes(StandardCharsets.UTF_8));
    }

    public String tokenRequest(String code, String redirectUri) throws IOException, InterruptedException {
        HttpClient httpClient = HttpClient.newBuilder()
                .build();

        String credential = String.format("Basic %s", clientSecretBasic());

        String template = "grant_type=authorization_code&code=%s&redirect_uri=%s";
        String body = String.format(template, code, redirectUri);

        HttpRequest request = HttpRequest.newBuilder()
                .uri(tokenEndpoint)
                .header("Content-Type", "application/x-www-form-urlencoded")
                .header("Authorization", credential)
                .POST(HttpRequest.BodyPublishers.ofString(body))
                .build();
        HttpResponse<byte[]> response = httpClient.send(request, HttpResponse.BodyHandlers.ofByteArray());

        Map<String, Object> json = mapper.readValue(response.body(), new TypeReference<Map<String, Object>>() {});
        if (json.containsKey("error")) {
            logger.error(json.get("error").toString());
        }
        return json.get("id_token") instanceof String idToken ? idToken : null;
    }
}

設定ファイル

#

サービスの設定を application.yaml ファイルに記述します。

openapi:
  exampleService:
    base-path: /example

auth:
  authorizationEndpoint: https://keycloak.example.com/auth/realms/passengers/protocol/openid-connect/auth
  tokenEndpoint: https://keycloak.example.com/auth/realms/passengers/protocol/openid-connect/token
  clientId: hibernation-pod
  clientSecret: SECRETSECRETSECRET
  callback: /callback

clientSecret に Keycloak の設定時に記録した値を設定してください。

参考

#

OpenID Connect の Discovery 仕様 に設定ファイルで使用する、Authorization Endpoint や Token Endpoint また ID トークン等の署名検証時の公開鍵取得のエンドポイントの取得方法も記載されています。

Keycloak の場合は、次の URL にアクセスして取得できます。

  • https://[Keycloak のホスト]/auth/realms/[レルム名]/.well-known/openid-configuration

Amazon Cognito Userpools の場合は、次の URL にアクセスして取得できます。

  • https://cognito-idp.[REGION].amazonaws.com/[POOL ID]/.well-known/openid-configuration

まとめ

#

この記事では、Keycloak を OpenID Connect の IdP (アイデンティティプロバイダ) として設定する方法について説明しました。この記事のコード全体は、GitHub リポジトリ にあります。

関連記事

#

  1. QRコードは株式会社デンソーウェーブの登録商標です。 ↩︎

豆蔵では共に高め合う仲間を募集しています!

recruit

具体的な採用情報はこちらからご覧いただけます。