First Party CookieのSameSite属性を試す。

一昔前に話題になっていたので、いまさらながらCookieのSameSite属性について調べた。 First Party Cookieと、Third Party Cookieがあるが、今回はFirst Party Cookieの確認。 *1

一言でSameSiteの設定の影響について説明するなら、他のドメインからのアクセスがあったときのCookie送信の振る舞いが変えるための設定。

CookieのSameSite属性の基本(First Party Cookie)

例えば、mydomain.comのサイトでSameSiteを設定すると、他のドメインxxxx.comからmydomain.comにアクセスがあったときに、mydomain.comで設定されているCookieが送信されるかどうかが変わる。

SameSiteの設定を変えると、どのように変わるかは、以下の制約が厳しい順にみていく。

  • Strict
  • Lax
  • None

SameSite=Strict

他のドメインからアクセスがあった場合、一切Cookieが送信されなくなる。

具体的には、aタグとかのリンクで、mydomain.comにアクセスした場合でも、最初のアクセスではCookieは送信されないということ。リロードすればCookieは送信される。

SameSite=Lax

他のドメインからPOSTでアクセスがあった場合、Cookieが送信されなくなる。 他のドメインからGETでのアクセスの場合は、Cookieは送信される。

具体的には、formのドメインとpost先のドメインが違うような場合に、Cookieは送信されないということ。

Strictとは違って、aタグとかのリンクで、他のドメインからアクセスした場合は影響はない。

SameSite=None

他のドメインからのアクセスであっても、常にCookieは送信される。

SameSite設定の影響についての考察

さて、騒がれていたのはChrome80以降では以下のように仕様が変わるとのこと。

  • SameSite=Laxがデフォルトになる。
  • SameSite=Noneを設定する場合は、Secureの設定が必須になる。

で、NoneからLaxにある日突然切り替わると困るのは、クロスドメインでPOSTをやっている場合ということになる。

これどういうときに困るかというと、Webアプリを運用する場合だと、domain1.comのAPIサーバをdomain2.comにしていて、domain1.comからdomain2.comにPOSTでアクセスするようなケースあたりが考えられそう。セッションIDをCookieに保存していると一時的にセッションが切れてしまうわけでめんどくさい。

あとは、決済関係とかは他のドメインからpostされることも多そうなので影響大きそう。

多少不便になるケースもあるが、Laxを設定しておけば、CSRF対策として一定の効果はありそう。一方で、CSRFトークンとかで対策されているやん?という話もあるが。

*1:最近の話題だからか、徳丸本にも記載なかったし、Real World HTTPについては記載が誤っていたり結構混乱した。

First Party CookieとThird Party Cookie

Cookieの仕様について調べているが、 どうもFirst Party Cookieと、Third Party Cookieってのがあるらしい。

普通のCookie、表示して表示しているページと同じドメイン(domainAとする)のCookieはFirst Party Cookieというらしい。

それに対して、表示しているページと違うドメイン(domainBとする)のCookieをThird Party Cookieというらしい。 主に広告などでユーザをトラッキングするために使われている様子。

通常、Cookieは、同じドメイン内でしか設定や送信ができないはずなので、 最初は、「表示しているページと違うドメイン」の意味がわからなかった。

よく考えてみると、表示しているdomainAのindex.htmlから、 domainBのscript.jsを読み込むような場合には、同時にdomainBのCookieを設定したり読み込んだりすることはできることがわかった。 domainBのscript.jsを読み込む場合に、「レスポンスのヘッダ」*1Cookieを設定してやればよい。jsじゃなくてもcssとかpngとかでも同じ。

Javascriptで設定するCookieは?

ちなみに、domainB(表示しているページと異なるドメイン)のjavascriptを読み込んで、以下のように実行時にCookieを設定してもThird Party Cookieにはならない。

document.cookie = 'key=secret';

このスクリプトは読み込まれたあとに、domainAで実行される*2ので、First Party Cookieになる。

Third Party Cookieを使ったトラッキング

ここで、Cookieの基本を確認する。

Cookieは設定されたドメインと同じドメインにしか送信されない。 - domainAのCookieはdomainAのリクエスト時にのみ送信される。 - domainBのCookieはdomainBのリクエスト時にのみ送信される。

しかし以下のような場合は、異なるドメインをまたいでCookieが送信されているような状況が起きる。

  • domainAを表示中にdomainBにリクエストする場合は、domainBにdomainBのCookieが送信される。
  • domainCを表示中にdomainBにリクエストする場合にも、domainBにdomainBのCookieが送信される。

こういう仕組みでトラッキングがされている様子。なるほどー。 はてなブックマークのボタンとかGoogle Analyticsのscriptとかそういうのでトラッキングできますね。 CDNとかでもできそう。

ただ、Third Party Cookieへの風当たりは強いようなので、今後は廃止されていくのかも?

*1:ほかにもある?

*2:domainAから読み込まれていても、domainBから読み込まれていても区別されない

CookieのSecure属性を試す。

CookieのSecure属性を付けないとどうなるか試した。Secure属性を有効にすると、httpsのときのみCookieを送信するようになる。

事前準備

事前の準備として、検証の内容的にhttpsアクセスは必須なので、nginxのリバースプロキシでhttps化の設定しておく。*1

今回は以下のアドレスで、expressにアクセスできるようにしておく。

https://127.0.0.1/

また、httpでアクセスした場合には、httpsにリダイレクトするようにしておく。

この設定自体はよくあると思う。

Cookieの設定

まずexpressで以下のようにSecure属性なしでCookieを設定

var app = express();

app.use(function(req, res, next) {
  res.setHeader("Set-Cookie", "key=secret");
  next();
});

HTTPSアクセスしてみる。

https://127.0.0.1/にアクセスして、Chromeの開発者ツールなどで確認すると、Response Headerに以下が設定される。

set-cookie: key=secret; Secure

もう一度アクセスすると以下のようにsetされたcookieがRequest Headerに含まれることが確認できる。

cookie: key=secret

ここまでは通常通り。

HTTPアクセスした場合。

次に、http://127.0.0.1/にアクセスしてみる。そうすると当然ではあるのだがヘッダに以下のcookieが送信させていることが確認できた。

cookie: key=secret

つまり、httpの暗号化していない通信でkey=secretが送信されている。 これリダイレクトさせてるとほとんど気付かないが、開発者ツールなどでちゃんと確認していくと確認できる。*2

そもそもCookieってどういう条件で送付するんだっけ、とわからなくなったが、同一ドメインで指定したパス以下であればCookieは送付されるよう。同一オリジンと誤解している書籍などもあるが、オリジンではない様子。

CookieにSecure属性を追加

そこでSecure属性を追加して振る舞いの変化を確認してみる。

var app = express();

app.use(function(req, res, next) {
  res.setHeader("Set-Cookie", "key=secret; Secure");
  next();
});

そのあと、同様の手順でhttpsCookieを設定し、httpでリダイレクト試す。

そうすると、httpsのときだけしか、

cookie: key=secret

は送信されなくなった。httpのリダイレクトの時には送信されず、 httpsになった時だけ送信されるようになった。

httpのリンクを踏まされてしまうと漏らしてしまいかねないので、確かに怖い。*3 Secure属性の設定は必須ですね。。。

*1:設定方法は過去記事にも書いた。https://ma38su.hatenablog.com/entry/2020/06/27/235451

*2:ほんとうはパケットキャプチャとかまで試すべきなのかもしれないが。。。

*3:もちろんパケットキャプチャを仕掛けられている場合に限るのだけど、、、

NGINXでロードバランサを試す。

docker-composeでnginxでロードバランサを試した。 nginxの後ろに、3台のexpressのサーバに振り分けるようなものを試してみた。

version: '3'
services:
  lb:
    image: nginx:latest
    volumes:
      - ./nginx/nginx.conf:/etc/nginx/nginx.conf
    ports:
      - "80:80"
    depends_on:
      - express1
      - express2
      - express3
  express1:
    build:
      context: ./express
    ports:
      - "3000:3000"
  express2:
    build:
      context: ./express
  express3:
    build:
      context: ./express

nginx.confはこんな感じ。

user  nginx;
worker_processes  1;

error_log  /var/log/nginx/error.log warn;
pid        /var/run/nginx.pid;

events {
    worker_connections  1024;
}

http {
    include       /etc/nginx/mime.types;
    default_type  application/octet-stream;

    log_format  main  '$remote_addr - $remote_user [$time_local] "$request" '
                      '$status $body_bytes_sent "$http_referer" '
                      '"$http_user_agent" "$http_x_forwarded_for"';

    access_log  /var/log/nginx/access.log  main;

    sendfile on;
    #tcp_nopush on;
    keepalive_timeout 65;
    server_tokens off;

    #gzip  on;
    #gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript application/grpc application/octet-stream;

    upstream backend {
        server express1:3000;
        server express2:3000;
        server express3:3000;
    }

    server {
        listen 80 default_server;
        server_name 127.0.0.1;

        location / {
            proxy_pass http://backend;
        }
    }
}

nginx.confの設定を見ていく。

まず、ここでバックエンドの3台に振り分ける設定は以下で書いている。

    upstream backend {
        server express1:3000;
        server express2:3000;
        server express3:3000;
    }

次に、ここでロードバランサを有効にするパスを設定している。今回はルートにしている。

        location / {
            proxy_pass http://backend;
        }

立ち上げて、ブラウザでアクセスしてみると、こんな感じでアクセスが振り分けられた。

express1_1  | GET / 304 30.441 ms - -
express2_1  | GET /stylesheets/style.css 304 2.718 ms - -
express3_1  | GET / 304 31.145 ms - -
express1_1  | GET /stylesheets/style.css 304 0.943 ms - -

とりあえずここまで。

自己署名証明書の作り方とnginxの設定まで。

いわゆるオレオレ証明書の作り方。

最近、ちょっとしたことを確認しようとしても、https化が必須になっている。 パブリックなネットワークだとLet's Encryptを使うべきなんだけど、 Let's Encryptではドメインが必須なので、ドメインが容易できないと使えない。 またローカルで試したいときなどはオレオレ証明書のほうが便利だったりもするので、 オレオレ証明書が欲しいときもある。

そんなわけでオレオレ証明書の作り方からnginxの設定まで手順を残しておく。

ちなみにlocalhostだと有効な証明書は作れなかったが、127.0.0.1の証明書ならできた。

秘密鍵と証明書を作成する。

IP=127.0.0.1

echo "subjectAltName = IP:${IP}" > san.txt

# gen private key 秘密鍵を作成する。
openssl genrsa -out ${IP}.key 2048

# gen CSR 署名要求(公開鍵を含む)を作成する。
openssl req -new -key ${IP}.key > ${IP}.csr

# gen CRT 公開鍵を秘密鍵で署名して、証明書を作成する。
openssl x509 -in ${IP}.csr -out ${IP}.crt -req -signkey ${IP}.key -days 3650 -extfile san.txt

CSRを作成時には多少入力が必要だが、common nameにIPを入れておくくらい?

nginxのHTTPS化

/etc/nginxに証明書と秘密鍵を配置する。(Ubuntu)

sudo mv 127.0.0.1.crt /etc/nginx
sudo mv 127.0.0.1.key /etc/nginx

/etc/nginx/conf.d/default.confを編集する。

  • portをHTTPSの443に変更する。
  • 証明書(*.crt)を設定する
  • 秘密鍵(*.key)を設定する
server {
    listen       443 ssl http2 default_server;

    ssl on;
    ssl_certificate      127.0.0.1.crt;
    ssl_certificate_key  127.0.0.1.key;
    (省略)

せっかくなのでHTTP/2も有効にしている。

tomcat8のHTTPS化

一緒にHTTPS化したのでメモ。

/var/lib/tomcat8/conf/server.xml

    <!-- Define a SSL/TLS HTTP/1.1 Connector on port 8443 with HTTP/2
         This connector uses the APR/native implementation which always uses
         OpenSSL for TLS.
         Either JSSE or OpenSSL style configuration may be used. OpenSSL style
         configuration is used below.
    -->
    <Connector port="8443" protocol="org.apache.coyote.http11.Http11AprProtocol"
               maxThreads="150" SSLEnabled="true" >
        <UpgradeProtocol className="org.apache.coyote.http2.Http2Protocol" />
        <SSLHostConfig>
                <Certificate certificateKeyFile="/etc/nginx/127.0.0.1.key"
                        certificateFile="/etc/nginx/127.0.0.1.crt"
                        certificateChainFile="/etc/nginx/127.0.0.1.crt"
                        type="RSA" />
        </SSLHostConfig>
    </Connector>

利用者の端末にオレオレ証明書をインストールする。

HTTPS化したサーバにアクセスする端末すべてにオレオレ証明書(*.crt)は事前にインストールしておくする必要がある。

Windowsの場合の手順

  1. *.crtを右クリックして、「証明書のインストール」
  2. 保存場所はそのまま(変更してもよいが)で「次へ」
  3. 「証明書をすべて次のストアに配置する」を選択して「参照」、「信頼されたルート証明期間」を選択して「OK」を押し、「次へ」を押す。
  4. 「完了」を押す。

Ubuntuの場合の手順

$ sudo cp 127.0.0.1.crt /etc/ssl/certs/
$ sudo update-ca-certificates

接続確認

Chromeでアクセスして鍵付きになればOK。

HTTP2を有効化していればHTTP/2 and SPDY indicatorの雷も青くなる。 https://chrome.google.com/webstore/detail/http2-and-spdy-indicator/mpbpobfflnpcgagjijhmgnchggcjblin?hl=en

threejs + typescript + webpackの環境構築

threejsで大規模なモデル操作とか始めるとTypescriptの型サポートはとても助かる。

以前にも書いていたことがあったが、古かったので消して、代わりに2020年度時点の環境設定を残しておく。

package.json
{
  "name": "threejs-tutorial",
  "version": "0.0.1",
  "description": "threejs-tutorial",
  "main": "src/index.ts",
  "scripts": {
    "build": "webpack --mode production",
    "dev": "webpack-dev-server",
    "start": "webpack-dev-server --open --mode development"
  },
  "devDependencies": {
    "@types/dat.gui": "^0.7.5",
    "@types/three": "^0.103.2",
    "css-loader": "^3.5.3",
    "html-webpack-plugin": "^4.3.0",
    "style-loader": "^1.2.1",
    "ts-loader": "^7.0.4",
    "typescript": "^3.9.3",
    "webpack": "^4.43.0",
    "webpack-cli": "^3.3.11",
    "webpack-dev-server": "^3.11.0",
    "worker-plugin": "^4.0.3"
  },
  "dependencies": {
    "dat.gui": "^0.7.7",
    "three": "^0.116.1"
  }
}
tsconfig.json

Typescriptの設定。

{
    "compilerOptions": {
        "target": "es2019",
        "module": "esnext",
        "moduleResolution": "node",
        "strict": true,
        "sourceMap": true,
        "esModuleInterop": true,
        "forceConsistentCasingInFileNames": true
    }
}
webpack.config.js

webpackの設定。babelは使っていない。

const path = require('path')
const HtmlWebpackPlugin = require('html-webpack-plugin');

module.exports = {
  entry: {
    app: './src/index.ts',
  },
  devtool: 'inline-source-map',
  devServer: {
    contentBase: './dist'
  },
  plugins: [
    new HtmlWebpackPlugin({
      title: 'threejs tutorial',
      meta: [
        {viewport: 'width=device-width, initial-scale=1'},
      ],
    })
  ],
  module: {
    rules: [
      {
        test: /\.tsx?$/,
        use: 'ts-loader',
        exclude: /node_modules/
      },
      {
        test: /\.css$/,
        use: ['style-loader', 'css-loader']
      }
    ]
  },
  resolve: {
    extensions: ['.tsx', '.ts', '.js']
  },
  output: {
    filename: '[name].bundle.js',
    path: path.resolve(__dirname, 'dist')
  },
};
src/index.ts

GLTFLoaderでglbのモデルを読み込んで、OrbitControlsでマウスでぐるぐるできるだけの簡単なサンプル。 このくらいだと型定義はほとんどないけど。

import * as THREE from 'three'
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader'
import { OrbitControls } from "three/examples/jsm/controls/OrbitControls";
import './style.css';

function createRenderer() {
    const scene = new THREE.Scene();
    const renderer = new THREE.WebGLRenderer({
        antialias: true
    });
    renderer.setClearColor(0x111111);
    renderer.setSize(window.innerWidth, window.innerHeight);

    const planeGeometory = new THREE.PlaneGeometry(60, 20);
    const planeMaterial = new THREE.MeshBasicMaterial({
        color: 0xcccccc
    });
    const planeMesh = new THREE.Mesh(planeGeometory, planeMaterial);
    planeMesh.rotation.x = -0.5 * Math.PI;
    planeMesh.position.x = 15;
    planeMesh.position.y = 0;
    planeMesh.position.z = 0;
    scene.add(planeMesh);

    const camera = new THREE.PerspectiveCamera(
        45,
        window.innerWidth/window.innerHeight,
        0.1,
        1000
    );
    camera.position.x = -30;
    camera.position.y =  40;
    camera.position.z = 30;
    camera.lookAt(scene.position);

    const light = new THREE.DirectionalLight(0xffffff);
    light.position.set(1, 1, 1).normalize();
    scene.add(light);

    const loader = new GLTFLoader();
    loader.load('./torus.glb', (glb) => {
        glb.scene.scale.set(10, 10, 10);
        scene.add(glb.scene);
        console.log('glb model loaded');
    });

    document.body.appendChild(renderer.domElement);

    const controls = new OrbitControls(camera, renderer.domElement);
    controls.update();

    return () => renderer.render(scene, camera);
}

function startAnimation(updateView: Function) {
    const update = () => {
        requestAnimationFrame(update);
        updateView();
    }
    update();
}

window.addEventListener("DOMContentLoaded", () => {
    const updateView = createRenderer();
    startAnimation(updateView);
});
src/style.css

全画面表示のためのスタイル

body {
    margin: 0;
    overflow: hidden;
}

あと、dist/torus.glbを適当につくって置いておけばOK。

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:全体的に認証、認可については、理解が完全ではないので、誤りの指摘は歓迎です。