Azure ADでOpenID Connect(OIDC)の流れを確認する

Azure ADを使ってOpenID Connect(OIDC)*1の「認可コードフロー」のクライアント(Webアプリ)側の流れを確認していく*2

公式のドキュメントは以下。 https://docs.microsoft.com/ja-jp/azure/active-directory/develop/v2-protocols-oidc

認可リクエストを要求する。

以下の認可サーバのエンドポイントに適切なパラメータを指定して、認可リクエストを要求する。

https://login.microsoftonline.com/common/oauth2/v2.0/authorize
?client_id=ZZZZ
&response_type=code
&response_mode=fragment
&scope=openid
&redirect_uri=http://localhost:3000/auth/callback
&state=xyz
&nonce=abc

(見やすさのため、改行を入れている。)

client_id=ZZZZ

アプリ登録時に表示されるクライアントID。 認可コードはこのクライアントID別に発行される。

クライアントIDは公開しても問題にはならない。

response_type=code

認可サーバに要求するresponseの種類を指定する。 ここでのcodeは、認可コードを要求することを示す。

response_mode=fragment

セキュリティの理由によりform_postが強く推奨されているが、 確認のため、今回はわかりやすいfragmentを指定した。

scope=openid

scopeは、ユーザーにリソースアクセスへの認可を求めるスコープ。 ちなみに、openidを指定しないとエラーになった。 Azure ADはOIDCの場合に限らずopenidの指定が必須?

redirect_uri=http://localhost:3000/auth/callback

このURLに認可サーバからリダイレクトされるURL。このURLに認可コード(response_typeで指定したデータ)が返される。 アプリ登録時に指定しておいたURLと一致する必要がある。

state=xyz

オプションだが、CSRF対策のために設定する。認証/認可リクエスト時にランダムな値を決める。

nonce=abc

認可コードフローでは別になくてもいいのかも。response_typeにid_tokenを指定した場合は必須になる。 認証/認可リクエスト時にランダムな値を決める。

認可コードを受け取る。

上記のURLにアクセスして、認可サーバでの認証が通ると、認可コードがリダイレクトで返ってくる。

http://localhost:3000/auth/callback?code=OAQABAAIAAAAm-.....-JlY3t76HnlOepcgAA&state=xyz&session_state=279c8370-69a2-4a97-8138-ac1711cb77b8

以下の部分が、認可コード。一部は省略している。

code=OAQABAAIAAAAm-.....-JlY3t76HnlOepcgAA

認可コードリクエストのときにstateを指定したので、認可コードと一緒にstateの値が返ってきている。 リダイレクトURLへのCSRF攻撃を防ぐため、認可コードリクエストのときに指定したstateと一致するstateが返ってきているかどうかを確認することが望ましい。

(バックエンドで)id_tokenを要求する。

リダイレクトで認可コードを受け取ったWebアプリは、バックエンドでid_tokenを要求する。

client_id=ZZZZ

認可コードの要求時と同様。

アプリ登録時に表示されるクライアントID。 認可コードはこのクライアントID別に発行される。

scope=openid

認可コードの要求時と同様。

client_secret=XXXXXX

アプリ毎に事前に発行する秘密鍵。アプリサーバ内で安全に管理する必要がある。値はダミー。

ちなみにSPAなど鍵を安全に管理できない場合は、クライアントシークレットの指定を省略したインプリシットフローがある。 インプリシットフローも無条件でクライアントシークレットを省略できるできるわけではなく、nonceの指定やid_tokenの証明書の検証が必須になったり、リフレッシュトークンが発行されないとか違いがある。

grant_type=authorization_code

認可コードフローの場合は、authorization_codeを指定する。 grant_typeってOAuthの言葉のはずなので少し気持ち悪いが、openidでもこれでよいのかな?

redirect_uri=http://localhost:3000/auth/callback

認可コードの要求時と同様の値を設定しておく。実施にリダイレクトはされない。

code=OAQABAAIAAAAm-.....-JlY3t76HnlOepcgAA

リダイレクトで受け取った認可コードを指定する。 認可コードは一度のみ有効なので、複数回同じ認可コードを使いまわそうとするとエラーが返ってくる。 また、認可コードの有効期限は一般的には数分程度と短く設定される。

ここではcurlでリクエストを作る。

curl -X POST -H "Content-Type: application/x-www-form-urlencoded" \
-d 'client_id=ZZZZ' \
-d 'scope=openid' \
-d 'client_secret=XXXXXX' \
-d 'grant_type=authorization_code' \
-d 'redirect_uri=http://localhost:3000/auth/callback' \
-d 'code=OAQABAAIAAAAm-.....-JlY3t76HnlOepcgAA' \
'https://login.microsoftonline.com/common/oauth2/v2.0/token'

すると、JSON形式でaccess_tokenとid_tokenが返ってきた。 scopeにはopenidしか指定していなかったはずだが、なぜかprofile, emailも含まれていた。

{
  "token_type": "Bearer",
  "scope": "openid profile email",
  "expires_in": 3599,
  "ext_expires_in": 3599,
  "access_token": "eyJ0e.....XjlkbiJaZBw",
  "id_token": "ey.....J9.eyJhdWQiOiJ.....iI6IjIuMCJ9.mYN07gxEwVAyfJmHG2_.....__V26W6nzoVQ"
}

token_typeのBearerとはtokenの保有のみで、誰がtokenを持っているかによらず、tokenの利用可否が決定されることを示す。

繰り返しになるが、このレスポンスはWebアプリがバックエンドで受け取るものである。 そのため、Webアプリ利用者がid_tokenやaccess_tokenの値を直接確認することはできない。 (確認されてしまうと問題。)

id_tokenの検証

id_tokenは署名付きのJWT(JSON Web Token)で構成される。JWTは、以下のようにヘッダとペイロードと署名がドットで結合されている。

(ヘッダ).(ペイロード).(署名)

この署名によって、id_tokenの正当性を証明できるようにしたところが、OIDCとOAuthの主な違いだという理解をしている。 (そのうえで、OIDCでは、nonceとかが追加されているのだが、認可コードフローの場合はnonceいらないかも?)

署名によって、JWTの改ざんの有無など検証できるのだが、認可コードフローの場合、httpsでid_tokenの通信経路が保護していれば、 id_tokenの発行元は信用する認可サーバであるので、署名の検証なしでもid_tokenのペイロードの内容だけ確認すれば認証を完了させることができる、と理解している。

ヘッダとペイロードbase64エンコードされているので、それぞれ分割してbase64デコードすればそれぞれJSONが得られる。

ペイロード

検証したいペイロードの内容から確認していく。

echo 'eyJhdWQiOiJ.....iI6IjIuMCJ9' | base64 -d | jq

デコードとして、jqで整形すると以下のようなJSONが得られる。

{
  "aud": "ZZZZ",
  "iss": "https://login.microsoftonline.com/YYYY/v2.0",
  "iat": 1592747582,
  "nbf": 1592747582,
  "exp": 1592751482,
  "nonce": "abc",
  "sub": "XXXX",
  "tid": "YYYY",
  "uti": "VbCxLeDmH0a68F1FTW15AA",
  "ver": "2.0"
}

このJSONから以下について確認して問題がなければ認証完了できる。 確認する内容は以下の通りである。

  • audは、アプリのクライアントID
  • issは、id_token発行元のURL。
  • iatは、JWTの発行時刻(UNIX タイムスタンプ)
  • nbfは、JWTが有効となる時刻(基本的にはiatと同じ?)
  • expは、JWTが有効期限切れとなる時刻(このトークンは3,900秒有効)
  • nonceは、認可リクエスト時に指定したID。
  • utiは、Azure AD内部で管理する値で、無視してよい。
  • subは、ユーザを一意に識別するための値。認証にはこの値を使う必要がある。(ダミー)
  • tidは、Azure ADのテナントID。(ダミー)

ヘッダ

認可サーバとの通信がhttpsでない場合は、JWTの署名を検証する必要がある。 あとは、また、認可サーバから直接id_tokenを受け取らないフローの場合も署名の検証は必要である。

JWTの署名を検証するための情報がヘッダにあるので、とりあえず、中身を見てみる。

echo 'ey.....J9' | base64 -d | jq

デコードした結果は以下。

{
  "typ": "JWT",
  "alg": "RS256",
  "kid": "SsZsBNhZcF3Q9S4trpQBTByNRRI"
}

algは署名のアルゴリズムでRS256が指定されている。RS256は公開鍵を使った署名。 kidは署名に使う公開鍵のThumbprint。公開鍵は複数用意されており定期的に変更されるので、kidでどの公開鍵を使ったかが明示されている。

公開鍵の一覧はJWK Set Documentで確認できる。、マルチテナント(common)の場合は以下のURLから確認できる。 https://login.microsoftonline.com/common/discovery/v2.0/keys

具体的には以下のようなJSONに複数の公開鍵が公開されており、kidが一致するnとeがJWTの署名を検証するための公開鍵に相当する。

{
  "keys": [
    {
      "kty": "RSA",
      "use": "sig",
      "kid": "SsZsBNhZcF3Q9S4trpQBTByNRRI",
      "x5t": "SsZsBNhZcF3Q9S4trpQBTByNRRI",
      "n": "uHPewhg4WC3eLVPkEFlj7RDtaKYWXCI5G-.....B4cqjfJqoftFGOG4x32vEzakArLPxAKwGvkvu0jToAyvSQ",
      "e": "AQAB",
      "x5c": [
        "MIIDBTCCAe2gAwIBAgIQWHw7h/...../O61G2dzpjzzBPqNP"
      ],
      "issuer": "https://login.microsoftonline.com/{tenantid}/v2.0"
    },
    ....
  ]
}

nとeからいつもの公開鍵(pem)を作ってみる。 rsa-pem-from-mod-exptが使える。

const getPem = require('rsa-pem-from-mod-exp');

// n
const modulus = "uHPewhg4WC3eLVPkEFlj7RDtaKYWXCI5G-LPVzsMKOuIu7qQQbeytIA6P6HT9_iIRt8zNQvuw4P9vbNjgUCpI6vfZGsjk3XuCVoB_bAIhvuBcQh9ePH2yEwS5reR-NrG1PsqzobnZZuigKCoDmuOb_UDx1DiVyNCbMBlEG7UzTQwLf5NP6HaRHx027URJeZvPAWY7zjHlSOuKoS_d1yUveaBFIgZqPWLCg44ck4gvik45HsNVWT9zYfT74dvUSSrMSR-SHFT7Hy1XjbVXpHJHNNAXpPoGoWXTuc0BxMsB4cqjfJqoftFGOG4x32vEzakArLPxAKwGvkvu0jToAyvSQ";

// e
const exponent = "AQAB";

// public key
const cert = getPem(modulus, exponent);

console.log(cert)

するといつものpemが出力される。

-----BEGIN RSA PUBLIC KEY-----
MIIBCgKCAQEAuHPewhg4WC3eLVPkEFlj7RDtaKYWXCI5G+LPVzsMKOuIu7qQQbey
tIA6P6HT9/iIRt8zNQvuw4P9vbNjgUCpI6vfZGsjk3XuCVoB/bAIhvuBcQh9ePH2
yEwS5reR+NrG1PsqzobnZZuigKCoDmuOb/UDx1DiVyNCbMBlEG7UzTQwLf5NP6Ha
RHx027URJeZvPAWY7zjHlSOuKoS/d1yUveaBFIgZqPWLCg44ck4gvik45HsNVWT9
zYfT74dvUSSrMSR+SHFT7Hy1XjbVXpHJHNNAXpPoGoWXTuc0BxMsB4cqjfJqoftF
GOG4x32vEzakArLPxAKwGvkvu0jToAyvSQIDAQAB
-----END RSA PUBLIC KEY-----

JWTの署名検証

一通り確認してみたいので、さきほど作った公開鍵を使って、JSONの検証をしてみる。

const jwt = require('jsonwebtoken');
const getPem = require('rsa-pem-from-mod-exp');

// jwt
const token = 'ey.....J9.eyJhdWQiOiJ.....iI6IjIuMCJ9.mYN07gxEwVAyfJmHG2_.....__V26W6nzoVQ';

// n
const modulus = "uHPewhg4WC3eLVPkEFlj7RDtaKYWXCI5G-LPVzsMKOuIu7qQQbeytIA6P6HT9_iIRt8zNQvuw4P9vbNjgUCpI6vfZGsjk3XuCVoB_bAIhvuBcQh9ePH2yEwS5reR-NrG1PsqzobnZZuigKCoDmuOb_UDx1DiVyNCbMBlEG7UzTQwLf5NP6HaRHx027URJeZvPAWY7zjHlSOuKoS_d1yUveaBFIgZqPWLCg44ck4gvik45HsNVWT9zYfT74dvUSSrMSR-SHFT7Hy1XjbVXpHJHNNAXpPoGoWXTuc0BxMsB4cqjfJqoftFGOG4x32vEzakArLPxAKwGvkvu0jToAyvSQ";

// e
const exponent = "AQAB";

// public key
const cert = getPem(modulus, exponent);

console.log(cert);

jwt.verify(token, cert, function(err, decoded) {
    if (err) {
        console.log('error: ', err)
        return;
    }
    console.log('result: ', decoded)
});

検証に成功すれば以下のような結果が返ってくる。

result:  {
  aud: 'e02bd292-b5b6-462a-9278-780823262330',
  iss: 'https://login.microsoftonline.com/f54277c9-dafe-44aa-85a4-73d5c7c52450/v2.0',
  iat: 1592964084,
  nbf: 1592964084,
  exp: 1592967984,
  aio: 'ATQAy/8PAAAAXPCmIRlMDuwvMcHFlw8g4xw3aYMmi8/XqYKRffi3DbMDPtCK7ZcUvbd4OnnD1p4X',
  nonce: 'abc',
  sub: 'XXXX',
  tid: 'YYYY',
  uti: 'GXOYdmnjNk--8vGGqxtAAQ',
  ver: '2.0'
}

このあと

とりあえず認証はできるようになったが、認証後、認証状態をどう管理するべきかは考えないといけなそう。 openidをそのままトークンとすることもできそうだが、sessionで管理してもよいのか?

最近、Cookieも変わっているみたいだし、まだまだわからんことばかりである。

*1:OAuth2のうち、open_idで認証するのがOIDCという理解です。

*2:全体的に認証、認可については、理解が完全ではないので、誤りの指摘は歓迎です。