エムスリーテックブログ

エムスリー(m3)のエンジニア・開発メンバーによる技術ブログです

フルスクラッチして理解するOpenID Connect (3) JWT編

こんにちは。デジカルチームの末永(asmsuechan)です。この記事は「フルスクラッチして理解するOpenID Connect」の全4記事中の3記事目です。前回はこちら。

www.m3tech.blog

今回は全4回中の第3回目です。

  • (1) 認可エンドポイント編
  • (2) トークンエンドポイント編
  • (3) JWT編
  • (4) stateとnonce編

9 JWT の実装

さて、前回固定値でお茶を濁した JWT をちゃんと実装していきます。

9.1 JWT概説

JWTはJSON Web Tokenの略で、実体は次のような文字列です。

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c

仕様は次のRFCで定義されています。

JWTの構成

JWT は . で区切られた 3 つの部分に分けることができ、それぞれヘッダー、ペイロード、署名となっています。ヘッダーとペイロードは base64url エンコードされた JSON で、署名はヘッダーとペイロードの電子署名を base64url エンコードしたものです。

※base64url エンコードはクエリパラメーターで使うためのもので、+や/など一部の文字が置換されます。

9.2 OpenID Connect の JWT

JWT で表される ID トークンの仕様については次のRFCで記述されています。

OpenID Connect ではbase64 decodeしたペイロードが次の項目を含むよう定義されています。なおこの部分をClaimと言います。

項目名 説明
iss ID トークンの発行元 http://localhost:3000
sub IdP 内でユニークな文字列 363cf11c-2170-45b5-aecc-fcc66abb2654
aud ID トークンを受け取る RelyingParty の使う client_id tiny-client
exp ID トークンの有効期限 1706990578
iat ID トークンの発行日時 1706990278

ヘッダーの定義を次に示します。このヘッダーはJOSEヘッダーと呼ばれるもので、署名で使ったアルゴリズムなどについての情報を記載します。

項目名 説明
typ JWTの種別。tiny-idpでは常にJWT。JWTのRFCで定義 JWT
cty JWTが暗号化される場合などに使う。tiny-idpでは使わない。JWTのRFCで定義 JWT
alg 署名アルゴリズム。JWAのRFCで定義 RS256
kid 非対称鍵を使った署名の場合、署名の鍵を表すID。Key ID。 Akej922mBd

ヘッダーの内容により、どの方式で署名されたかなどを判別します。tiny-idpのJWTはJWSの非対称鍵で署名するようにします。ですので、ヘッダーはtiny-idpでは次のようにします。

{
  "typ": "JWT",
  "alg": "RS256",
  "kid": "(IDトークンを一意に示すランダムな文字列、署名した鍵と紐づく)"
}

9.3 ヘッダーとペイロードの実装

上記に従って JwtService クラスを作ります。まずは上記のテーブルに従ってヘッダーとClaimの型を作ります。

// src/services/jwt_service.ts
type JwtPayload = {
  iss: string;
  sub: string;
  aud: string;
  exp: number;
  iat: number;
};

type JwtHeader = {
  alg: string;
  typ: string;
  kid: string;
};

さて、次にJwtService本体のクラスを作ります。処理の流れを次に示します。

  • ヘッダーとClaimのオブジェクトを作る
  • 署名部分は固定値で signature (後ほど実装)
  • base64エンコードする
  • base64url形式にする
  • .で繋いだ文字列にする
// src/services/jwt_service.ts
export class JwtService {
  get ONE_DAY(): number {
    return 60 * 60 * 24;
  }

  public generate(iss: string, aud: string, expDuration: number = this.ONE_DAY): string {
    const encodedHeader = this.base64urlEncode(JSON.stringify(this.buildHeader('2024-03-10')));
    const encodedPayload = this.base64urlEncode(JSON.stringify(this.buildPayload(iss, aud, expDuration)));
    const signTarget = `${encodedHeader}.${encodedPayload}`;
    const signature = this.sign(signTarget);
    return `${signTarget}.${this.base64urlEncode(signature)}`;
  }

  private sign(target: string) {
    return 'signature';
  }

  private buildHeader(kid: string): JwtHeader {
    return {
      alg: 'RS256',
      typ: 'JWT',
      kid: kid
    };
  }

  private buildPayload(iss: string, aud: string, expDuration: number = this.ONE_DAY): JwtPayload {
    const sub = Math.random().toString(16).slice(2);
    const iat = Math.floor(Date.now() / 1000);
    const exp = iat + expDuration;
    return { iss, sub, aud, exp, iat };
  }

  private base64urlEncode(input: string) {
    return Buffer.from(input).toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '');
  }
}

このクラスを使うように token_controller.ts を変更しましょう。

// src/controllers/token_controller.ts
import { JwtService } from '../services/jwt_service'; // 追加
()
  const jwtService = new JwtService(); // 追加
  const jwt = jwtService.generate('http://localhost:3000', 'tiny-client'); // 追加
  const data: ResponseData = {
    id_token: jwt, // 変更
    access_token: accessToken.token,
    token_type: 'Bearer',
    expires_in: 86400
  };

tiny-idpを npm run build && node lib/index.js で再起動し、ログインを試してみます。

JWTの文字列が出てくるようになりました。このJWTをコピーして jwt.io に貼り付けると、署名部分は未実装なのでInvalid Signatureは出ますが先ほど入力した内容が出てきます。

9.4 署名の実装

JWT の RFC には、HS256 と none は実装してね、と書かれています。RS256 と ES256 は実装を推奨されています。しかしここでは実装を簡略化するため、OpenID Connect でデフォルトとすることが推奨されている RS256 のみを実装します。

  • HS256
    • HMAC-SHA256
      • SHA256 でハッシュ値を計算して HMAC で署名する
    • 対象アルゴリズム
      • 署名の作成と検証に共通の鍵を使う
    • 共通鍵は client_secret の値に由来する
  • RS256
    • RSA-SHA256
      • SHA256 でハッシュ値を計算して RSA で署名する
    • 非対称アルゴリズム
      • 署名の作成と検証に秘密鍵と公開鍵を使う
    • 公開鍵は JWKS エンドポイントで公開される
    • ID トークンでデフォルトのアルゴリズムとして使うことが推奨されている

公開鍵と秘密鍵を生成する

では、RS256 での署名と検証に使うための RSA 公開鍵と秘密鍵を生成します。openssl コマンドで生成します。

$ openssl genrsa -out tiny_idp_private.pem 2048
$ openssl rsa -in tiny_idp_private.pem -pubout -out tiny_idp_public.pem

この公開鍵と秘密鍵は keys/ に置きます。

署名処理を作る

次に、署名ロジックを作ります。sign メソッドを書き換えます。

cryptoパッケージの次の3関数を使って署名します。

実装の流れ次に示します。

  • createSign()関数でSignのオブジェクトを作る
    • crypto.getHashes()関数を使うと第一引数で使えるハッシュ関数の名前が分かる
    • ここでは'RSA-SHA256'を使う
  • Signのオブジェクトに update() 関数を使って署名対象の文字列を格納する
  • Signのオブジェクトに sign() 関数を実行し、引数で指定した秘密鍵で署名する
// src/services/jwt_service.ts
import crypto from 'crypto';
import path from 'path';
import fs from 'fs';

(中略)
   public sign(target: string) {
    const privatePath = path.resolve('./keys/tiny_idp_private.pem');
    const privateKey = fs.readFileSync(privatePath, "utf8");

    const sign = crypto.createSign('RSA-SHA256');
    sign.update(target);
    return sign.sign(privateKey, 'base64');
  }
}

これで RS256 による署名部分はできました。先ほどと同じように npm run build && node lib/index.js で再起動してJWTを取得します。

ではjwt.io でテストしてみましょう。このページでは次の手順で署名の検証もできます。

  • JWTをEncodedに貼り付る
  • AlgorithmがRS256であることを確認する
  • VERIFY SIGNATUREに公開鍵と秘密鍵をコピペする

「Signature Verified」と表示されますね。成功です。

10 JWKS URI の実装 (GET /openid-connect/jwks)

RelyingParty で ID トークンの検証をするには、JWT を署名した秘密鍵に対応した公開鍵が必要です(RS256 など非対称鍵を使った場合)。この公開鍵は JWK (JSON Web Key) のエンドポイントにより公開されます。

JWKエンドポイントのレスポンスは次のようになります。

{"keys":
  [
    {"kty":"EC",
     "crv":"P-256",
     "x":"MKBCTNIcKUSDii11ySs3526iDZ8AiTo7Tu6KPAqv7D4",
     "y":"4Etl6SRW2YiLUrN5vfvVHuhp7x8PxltmWWlbbM4IFyM",
     "use":"enc",
     "kid":"1"},

    {"kty":"RSA",
     "n": "0vx7agoebGcQSuuPiLJXZptN9nndrQmbXEps2aiAFbWhM78LhWx
4cbbfAAtVT86zwu1RK7aPFFxuhDR1L6tSoc_BJECPebWKRXjBZCiFV4n3oknjhMs
tn64tZ_2W-5JsGY4Hc5n9yBXArwl93lqt7_RN5w6Cf0h4QyQ5v-65YGjQR0_FDW2
QvzqY368QQMicAtaSqzs8KJZgnYb9c7d0zgdAZHzu6qMQvRL5hajrn1n91CbOpbI
SD08qNLyrdkt-bFTWhAI4vMQFh6WeZu0fM4lFd2NcRwr3XPksINHaQ-G_xBniIqb
w0Ls1jF44-csFCur-kEgU8awapJzKnqDKgw",
     "e":"AQAB",
     "alg":"RS256",
     "kid":"2011-04-29"}
  ]
}

参考: https://datatracker.ietf.org/doc/html/rfc7517#appendix-A.1

これはJWK Setと呼ばれるもので、1つ1つのKeyがJWKです。JWKで公開鍵を表すための形式を次に示します。

項目名 説明
kty 署名で使ったアルゴリズムを表す (必須) RSA
use この公開鍵がどのような用途なのかを表す sig
kid 鍵を表すID Akej922mBd
alg 鍵で使ってるアルゴリズムを表す RS256
n RSA公開鍵の法 (modulus) 長いのでRFCの付録参照
e RSA公開鍵の公開指数 (public exponential) AQAB

まずは src/controllers/jwks_controller.ts を追加し、形式通りにJWKの型を作ります。key_ops, x5u, x5c, x5tはJSON Web Key (JWK) Formatに書いてありますがtiny-idpのJWKでは使っていません。

ktyが定義上は必須なのにオプション型なのは、次の実装で使っているcryptoのJsonWebKey型に合わせたためです。後の実装でktyがなければエラーにするよう実装します。

// src/controllers/jwks_controller.ts
// https://datatracker.ietf.org/doc/html/rfc7517

// https://datatracker.ietf.org/doc/html/rfc7518#section-6.3.1
type JWK = {
  kty?: string; // https://datatracker.ietf.org/doc/html/rfc7517#section-4.1
  use?: string; // https://datatracker.ietf.org/doc/html/rfc7517#section-4.2
  kid?: string; // https://datatracker.ietf.org/doc/html/rfc7517#section-4.5
  key_ops?: string[];
  alg?: string;
  x5u?: string;
  x5c?: string[];
  x5t?: string;
  n?: string;
  e?: string;
};

// https://datatracker.ietf.org/doc/html/rfc7517#section-5.1
type JWKSet = {
  keys: JWK[];
};

次にsrc/services/jwk_service.tsを作り、JWKフォーマットの公開鍵を生成する関数を作ります。

// src/services/jwk_service.ts
import crypto from 'crypto';

export const generateJwk = (publicKeyPem: string) => {
  const publicKey = crypto.createPublicKey(publicKeyPem);
  return publicKey.export({ format: 'jwk' });
};

さて、jwks_controller.tsに戻ってgetJwks()関数を作ります。

// src/controllers/jwks_controller.ts
import { ServerResponse } from 'http';
import fs from 'fs';
import { generateJwk } from '../services/jwk_service';
()
export const getJwks = (res: ServerResponse) => {
  const pem = fs.readFileSync('keys/tiny_idp_public.pem', 'utf8');
  // NOTE: JWKとしてデータを保存して公開鍵・秘密鍵・kidを紐づけた方がいいが、ここでは処理を簡単にするために固定値としている
  const jwk = generateJwk(pem);
  jwk.kid = '2024-03-10';
  jwk.alg = 'RS256';
  jwk.use = 'sig';
  if (!jwk.kty) {
    res.writeHead(500, { 'Content-Type': 'application/json' });
    res.end(JSON.stringify({ error: 'failed to generate jwk' }));
  }
  // NOTE: tiny-idpはRS256のみで実装しているため、ここでは公開鍵1つしか公開しない
  const jwkSet: JWKSet = {
    keys: [jwk]
  };
  res.writeHead(200, { 'Content-Type': 'application/json' });
  res.end(JSON.stringify(jwkSet));
};

最後にsrc/index.tsを変更すれば完成です。

// src/index.ts
import { getJwks } from './controllers/jwks_controller';
()
  } else if (req.url?.split('?')[0] === '/openid-connect/jwks' && req.method === 'GET') {
    getJwks(res);

npm run build && node lib/index.jsで再起動し動作確認をします。

~/src/asmsuechan/tiny-idp-2 > curl localhost:3000/openid-connect/jwks
{"keys":[{"kty":"RSA","n":"uBp47VGURf-RrNo4UU7ydQuj3xkf4yaeE_CLddSkQSJ2luLSic_UDMr5-2izjqAcr6U-rGA3X8LMta7Y1McnnWA1hNbG2A2vc9Or59vFTlQqbxaLkSlLFo6K4MneJd-hMIcF7CvpWXULTcDXpF5b76ULenQoLvYIsBc3lWWq_pQuMufrK-SP2ZBYeG353kRCQdfi-b9aut8jZRbOH5q04viLSsOrgjiZaV5sszq-Vl83TOOmUHtRQkPx1hioztqhOczTqxUPoSdeFdwo5wvQ0M2cBohSDAcviOQMvfLGHeciabVdBuHeuQYfMvUojhTq0Ik34c0HJahmP7RE-PmZCQ","e":"AQAB","kid":"2024-03-10","alg":"RS256","use":"sig"}]}

このように返って来れば成功です。次の章で実際にJWKSエンドポイントを使ってRelyingPartyでJWTの検証をします。

11 RelyingParty で ID トークンの検証をする

ここまでで ID トークンの生成と公開鍵の公開には成功しました。では、RelyingParty にこの ID トークンの署名が有効なものであるかどうかをチェックするコードを追加します。

verify()関数が署名検証処理の本体です。

// tiny-rp/src/index.ts
import crypto from "crypto";
()
// NOTE: base64urlをデコードする便利関数
const base64urlDecode = (input: string) =>{
  input += "=".repeat(4 - (input.length % 4));
  return Buffer.from(
    input.replace(/-/g, "+").replace(/_/g, "/"),
    "base64"
  ).toString("utf-8");
}

const verifyToken = (token: string, jwk: string) => {
  const publicKey = crypto
    .createPublicKey({ key: jwk, format: "jwk" })
    .export({ format: "pem", type: "spki" });

  const [encodedHeader, encodedPayload, encodedSignature] = token.split(".");
  const signatureData = `${encodedHeader}.${encodedPayload}`;
  const verify = crypto.createVerify("RSA-SHA256");
  verify.update(signatureData);
  const decodedSignature = base64urlDecode(encodedSignature);
  return verify.verify(publicKey, Buffer.from(decodedSignature, "base64"));
};

callback のエンドポイントに次のコードを追加して署名の検証をします。

// tiny-rp/src/index.ts
()
    const idToken = tokenSet.id_token;
    const jwksUri = "http://localhost:3000/openid-connect/jwks";
    const jwks = await (await fetch(jwksUri)).json();
    const jwk = jwks.keys.find(
      (jwk: any) =>
        jwk.kty === "RSA" && jwk.alg === "RS256" && jwk.use === "sig"
    );
    const verified = verifyToken(idToken, jwk);
    if (verified) {
      res.status(200);
      res.json({ tokenSet });
      return;
    } else {
      res.status(401);
      res.json({ error: "invalid token" });
      return;
    }

npm run build && node lib/index.js でtiny-rpを再起動し、ログインを行います。

トークンセットが表示されれば成功です。

12 OpenID Connect Discovery エンドポイントの実装 (GET /openid-connect/.well-known/openid-configuration)

ここではディスカバリーエンドポイントを作ります。ディスカバリーエンドポイントとは、認可エンドポイントやトークンエンドポイント、各種設定を返すエンドポイントです。ディスカバリーエンドポイントで返す設定値はOpenID Provider Metadataと呼ばれるものです。

まずはconfiguration_controller.tsを作成します。ここでは全て固定値を返すようにします。

// src/controllers/configuration_controller.ts
// https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderMetadata

import { ServerResponse } from 'http';

export const getConfiguration = (res: ServerResponse) => {
  // https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderMetadata
  const configuration = {
    issuer: 'http://localhost:3000/openid-connect',
    authorization_endpoint: 'http://localhost:3000/openid-connect/auth',
    token_endpoint: 'http://localhost:3000/openid-connect/token',
    jwks_uri: 'http://localhost:3000/openid-connect/jwks',
    response_types_supported: ['code'],
    subject_types_supported: ['public'],
    id_token_signing_alg_values_supported: ['RS256'],
    scopes_supported: ['openid'],
    // https://openid.net/specs/openid-connect-core-1_0.html#ClientAuthentication
    token_endpoint_auth_methods_supported: ['client_secret_post'],
    claims_supported: ['sub', 'iss']
  };
  res.writeHead(200, { 'Content-Type': 'application/json' });
  res.end(JSON.stringify(configuration));
};

src/index.tsで上記のコントローラーを使うようにします。

OpenID Providers supporting Discovery MUST make a JSON document available at the path formed by concatenating the string /.well-known/openid-configuration to the Issuer.

とあるように、 /.well-known/openid-configuration をissuerに付けたものがディスカバリーエンドポイントのエンドポイントとなります。

// src/index.ts
import { getConfiguration } from './controllers/configuration_controller';
()
  } else if (req.url?.split('?')[0] === '/openid-connect/.well-known/openid-configuration' && req.method === 'GET') {
    getConfiguration(res);

tiny-rp側でも、jwks_uriをディスカバリーエンドポイントから取得するように変更します。

// tiny-rp/src/index.ts
    const configuration = await (
      await fetch(
        "http://localhost:3000/openid-connect/.well-known/openid-configuration"
      )
    ).json();
    const jwksUri = configuration["jwks_uri"];

これで完成です。

まとめ

今回はJWTの実装と署名の検証について取り上げました。次回はstateとnonceを実装します。

github.com

We're hiring

弊社では認証認可が好きな人もそうでない人もエンジニアを絶賛募集中です!

jobs.m3.com