背景
ElectronアプリでKeycloakと連携する記事の中で、「署名付きのJWTを利用したクライアント認証をするのは諦めた」と書いたが、無理やり実施する方法を考えたので、ちょっと試してみる。
前回の記事では、Electronアプリのようなクライアントの場合、「Access Type」は「public」にして、クライアント認証は実施せずに、PKCEで認可コード横取りに対処する感じにするのが良いという結論に至った。たぶんそれが一般的なんだと思う。
ただ、認証/認可サーバー側が「クライアント認証が必須」みたいな条件だった場合に(そんな条件あるのか分からないけど)、無理やりにでもクライアント認証する方法はないかなぁと思い、やり方を考えてみた。
※(注意)自学習を目的として書いています。この記事に記載の内容が最適なやり方とは限りません。
Signed JWTを利用したクライアント認証の導入
前回作成したElectronアプリにSigned JWTを利用したクライアント認証を導入する。
Electronアプリにそのまま秘密鍵を保持するのは危険なので、別途中継サーバーを立てる形で試してみる。
構成イメージ
イメージとしてはこんな感じ。
PKCEの仕組みによって、トークン取得のリクエストが、少なくとも認可リクエストを出したクライアントと同一であることは確認できる。
これに加えて署名付きのJWTを付与することに効果があるのかは分からないが、認証/認可サーバー側が「署名付きのJWTが必須」みたいな要件を出していたら、こうするかなという感じ(今回は認証/認可サーバーは自前のkeycloakなので、そんなことは起こりえないけど)。
keycloakの設定変更
前回設定したkeycloakのクライアントの設定を変更して、クライアント認証が必要な状態にする。
クライアントの「Access Type」を「Confidential」に変更する。
ここで一旦「save」すると、「Credentials」のタブが現れる。
「Credentials」のタブで「Signed JWT」を選択して、「Generate new keys and certificate」を押すと、キーペアの生成画面が表示されるので、「PKCS12」を選択し、適当にパスワードを設定して、「Generate and Download」ボタンを押下する。
「credentials」の画面に戻ると同時に「keystore.p12」がダウンロードされる。
keycloak側の設定変更は以上。
中継サーバーの構築
> mkdir relay
> cd relay
> yarn init
initはすべてデフォルトのまま。
続いてexpressのインストールを実施する。
> yarn add express
その他の必要な依存もインストールしておく。
> yarn add jsonwebtoken
> yarn add axios
> yarn add node-forge
プロジェクト直下にkeysというフォルダーを作成し、keycloakからダウンロードした「keystore.p12」ファイルを置いておく。
プロジェクト直下にrelay.jsというファイルを作成し、以下のように実装。
const jwt = require('jsonwebtoken');
const axios = require('axios');
const fs = require('fs');
const forge = require('node-forge');
const express = require("express");
const bodyParser = require('body-parser');
const app = express();
app.use(bodyParser.urlencoded({
extended: true
}));
const server = app.listen(3000, function(){
console.log("listening to PORT:" + server.address().port);
});
app.post("/relay/token", async function(req, res, next){
try {
const token = await loadTokens(req.body);
res.json(token);
} catch (error) {
next(error)
}
});
async function loadTokens(param) {
const params = new URLSearchParams();
params.append('grant_type', param.grant_type);
params.append('client_id', param.client_id);
params.append('code', param.code);
params.append('redirect_uri', param.redirect_uri);
params.append('code_verifier', param.code_verifier);
params.append('refresh_token', param.refresh_token); // 2021/01/09追記。リフレッシュトークンにも対応。
params.append('client_assertion_type', 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer');
params.append('client_assertion', generateClientAssertion());
try {
const response = await axios.post('http://localhost:8080/auth/realms/electron/protocol/openid-connect/token', params);
console.log(response);
return response.data;
} catch (error) {
console.error(error);
throw error;
}
}
function generateClientAssertion() {
const now = new Date();
const iatValue = now.getTime();
now.setMinutes(now.getMinutes() + 1);
const expValue = now.getTime();
const payload = {
aud: 'http://localhost:8080/auth/realms/electron/protocol/openid-connect/token', // トークンエンドポイントのURL
exp: expValue, // トークンの有効期限
jti: Math.random().toString(32).substring(2), // ユニークな値(今回はランダム文字列を簡易生成)
iat: iatValue, // トークンを署名した時刻
iss: 'http://localhost:3000/', // JWT を署名したクライアントの識別子
sub: 'test' // keycloakに登録したクライアントID
};
return sign(payload);
}
function sign(payload) {
const keyFile = fs.readFileSync('keys/keystore.p12'); // ファイル読み込み
const keyBase64 = keyFile.toString('base64'); // Stringで取得
const p12Der = forge.util.decode64(keyBase64); // base64からデコード
const p12Asn1 = forge.asn1.fromDer(p12Der); // ASN.1オブジェクトを取得
const p12 = forge.pkcs12.pkcs12FromAsn1(p12Asn1, 'test'); // p12として読み込み
const privateKey = p12.getBags({ bagType: forge.pki.oids.pkcs8ShroudedKeyBag })[forge.pki.oids.pkcs8ShroudedKeyBag][0].key; // 秘密鍵取得
const rsaPrivateKey = forge.pki.privateKeyToAsn1(privateKey); // RSA秘密鍵に変換
const privateKeyInfo = forge.pki.wrapRsaPrivateKey(rsaPrivateKey); // PrivateKeyInfoでラップ
const pemPrivate = forge.pki.privateKeyInfoToPem(privateKeyInfo); // PEM形式に変換
const signedJwt = jwt.sign(payload, pemPrivate, { algorithm: 'RS256'}); // JWTに署名
return signedJwt;
}
app.use(bodyParser.urlencoded(...))をやっておかないと、パラメータが読み込まれないので注意。
少し長いけど、やっていることはシンプル。
基本的には、POSTでリクエストを受け取って、そのパラメータをもとにkeycloakのトークンエンドポイントへのリクエストを作って投げているだけ。
追加で実施しているのは、「client_assertion_type」と「client_assertion」のパラメータを追加している点。
「client_assertion_type」は固定値で「urn:ietf:params:oauth:client-assertion-type:jwt-bearer」。
「client_assertion」はgenerateClientAssertion()で生成したクライアント認証用のJWT。
中身は、「aud」、「exp」、「jti」、「iat」、「iss」、「sub」を指定して作ったペイロードに、秘密鍵で署名している。
詳しくは↓の「private_key_jwt」部分を参照。
https://openid.net/specs/openid-connect-core-1_0.html#ClientAuthentication
楽に起動できるように、package.jsonに以下を追記しておく。
"scripts": {
"start": "node relay.js"
}
Electronアプリ側の修正
前回作成した実装からトークンのエンドポイントを中継サーバーに変更。
...
async function refreshTokens() {
...
if (refreshToken) {
...
try {
const response = await axios.post('http://localhost:3000/relay/token', params); // 2021/01/09追記。リフレッシュトークンにも対応。
...
} catch (error) {
...
}
} else {
...
}
}
async function loadTokens(callbackURL) {
...
try {
const response = await axios.post('http://localhost:3000/relay/token', params); // URLを変更
...
} catch (error) {
...
}
}
...
URL部分を変更しただけ。
確認
まずは、中継サーバーの立ち上げ。
> cd relay
> yarn start
続いて、前回作成したElectronアプリの実行。
> cd electron
> yarn start
初回アクセスなので、keycloakのログイン画面が表示される。
ユーザー名とパスワードを入力してログインしてみる。
無事にホームページが表示された。
ちなみに、秘密鍵を別のものに差し替えて実行すると、中継サーバーのコンソールログに下記のようなエラーが出力された。
一応、クライアント認証は効いているっぽい。
続いて、Electron側でトークン要求時の「code_verifier」の値だけを適当な値に変更して実行してみる(認可コードなどは正規のものを使用)。
中継サーバーのコンソールログに下記のようなエラーが出力された。
呼び出し元が認証時に生成した「code_verifier」の値を知らなければ、アクセストークンを取得できないことが確認できた。
さいごに
今回は、Electronアプリへの署名付きJWTを利用したクライアント認証導入を試してみた。
とりあえず、それっぽい動作をすることは確認できたが、これで本当に大丈夫なのかは、ちょっと自信がない。
そもそもこういったケースに対応する必要性がないのかもしれないけど、一般的なやり方があるのであれば知りたい。
2021/01/09追記
結局、PKCEは正規の(ネイティブアプリ)クライアントの場合には有効だけど、ユーザーの端末に偽のクライアントがインストールされてしまった場合には対応できないから、PKCEだけでは、やはり完全なクライアント認証の代替にはならないな。
そもそも、パブリックなネイティブアプリで、偽クライアントに対応する有効な方法はあるのだろうか。。。
2021/01/10追記
別の記事でちょっと考えてみました。