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

| 8 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 の設定

#

前回までの記事では、停止すると設定が失われる環境を説明しました。これは非常に不便なので停止しても設定が失われないようにします。Keycloak はデフォルトでは h2 データベースを使用します。データが保存されるディレクトリは、コンテナ内の /opt/jboss/keycloak/standalone/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: jboss/keycloak
volumes:
- ./datadir:/opt/jboss/keycloak/standalone/data
environment:
KEYCLOAK_USER: admin
KEYCLOAK_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コードは株式会社デンソーウェーブの登録商標です。 ↩︎

豆蔵デベロッパーサイト - 先週のアクセスランキング
  1. 自然言語処理初心者が「GPT2-japanese」で遊んでみた (2022-07-08)
  2. Tauri でデスクトップアプリ開発を始める (2022-07-08)
  3. Deno による Slack プラットフォーム(オープンベータ) (2022-09-27)
  4. Jest再入門 - 関数・モジュールモック編 (2022-07-03)
  5. ORマッパーのTypeORMをTypeScriptで使う (2022-07-27)
  6. 第1回 OpenAPI Generator を使ったコード生成 (2022-06-04)
  7. 直感が理性に大反抗!「モンティ・ホール問題」 (2022-07-04)
  8. Rust によるデスクトップアプリケーションフレームワーク Tauri (2022-03-06)
  9. 箱ひげ図で外れ値を確認する (2022-05-18)
  10. Nuxt3入門(第1回) - Nuxtがサポートするレンダリングモードを理解する (2022-09-25)