REST-APIにOIDCでユーザー認証を付加する


昨日、ユーザーIDをサインアップするWebサイトを作ったので、あるREST-APIがそのユーザーで認証されるように機能を付加してみようと。
IBM CloudのAPI Gatewayを使う。

AWS API Gatewayを使えた方が面白いかなと思ったのだが、IBM Cloud App IDで入手できるトークンをAWS側で受け入れてもらえないっぽいのでやめた。
https://docs.aws.amazon.com/ja_jp/apigateway/latest/developerguide/http-api-jwt-authorizer.html
具体的には、IBM Cloud App IDのJWTはnbf属性を持っていないので、非互換だろうと。
こんなところで断絶があるとは。

REST-APIサーバーを作る

1.仮想サーバーを作る。
Ruby、Sinatraでいこう。
AWS Lightsailに$3.5のUbuntu 20.04マシンを作る。
なお、今回のサーバーをDNSに登録する必要はない。

2.RubyとSinatraをインストールする。

$ sudo -i
# apt update
# apt install ruby
# gem install -N sinatra

3.REST-APIのプログラムを作る。
以下のファイルを作成する。
プログラムは、PUTで渡されたjsonデータをファイルに保存して、GETでその保存したデータを返すというもの。

micro.rb
require 'sinatra'
require 'json'
set :bind => '0.0.0.0'
set :port => '80'
set :default_content_type => 'application/json'

put '/micro' do
  begin
    b = request.body.read
    d = JSON.parse(b)
    File.write("data/" + d["name"], b)
    '{"result":"success"}'
  rescue => error
    puts error.backtrace
    '{"result":"error"}'
  end
end

get '/micro' do
  begin
    f = "data/" + params[:name]
    puts f
    if File.exists?(f) then
      File.read("data/" + params[:name])
    else
      "data not exists"
    end
  rescue => error
    puts error.backtrace
    '{"result":"error"}'
  end
end

4.REST-APIプログラムを起動する。

# mkdir data
# ruby micro.rb

API Gatewayを作る

1.IBM Cloudのカタログから「API Gateway」を検索し、サービスを作る。

似たようなサービスでAPI Connectというのもあるが、まあ、インスタンスを作ろうとするとエラーになるので気にしないことにした。IBM Cloudでは良くあることだ。

2.サービスのインスタンスが出来たら、APIプロキシーを作る。

3.API名に「micro」、パスに「/micro」、URLに「http://<REST-APIサーバーのIPアドレス>/micro」とそれぞれ入力する。

URLはHTTPS&FQDNの方がいいんだろうが、HTTP&IPアドレスでもリクエスト振ってはくれる。API GatewayがHTTPSリバースプロキシになってくれるというのも一つの価値ではないかと思う。

4.画面を下にスクロールし、OAuth認証を有効にする。
プロバイダーは、昨日作成したApp IDを指定する。

5.Locationは、まあTokyoにするか。指定後、「Create」をクリック。

6.APIを定義すると、以下の画面が表示される。
画面中にある、「Route」のURLがREST-APIサーバーのリバースプロキシーになる。

アクセスしてみる

API Gateway経由でREST APIにアクセスするには、それにアクセス可能なOIDCトークンを入手する必要がある。
具体的には、昨日App IDにサインアップしたユーザーでApp ID側にログインし、JWT形式のトークンを入手する必要がある。

1.トークンを入手するためには、それを入手できるURLから調べないといけない。
まずは、curlコマンドで昨日入手した"discoveryEndpoint"(App IDの資格情報から入手できる)にアクセスする。
本日作ったREST-APIサーバーでも、昨日のWebサーバーでもどちらでもいいが、プロンプトから以下コマンドを実行する。
ここで欲しいのはtoken_endpointの値である。

# curl https://jp-tok.appid.cloud.ibm.com/oauth/v4/3026b4fb-f9c5-4636-a632-535e7693c0ee/.well-known/openid-configuration | python -m json.tool

(実行結果)
[root@www ~]# curl https://jp-tok.appid.cloud.ibm.com/oauth/v4/3026b4fb-f9c5-4636-a632-535e7693c0ee/.well-known/openid-configuration | python -m json.tool
...
    "token_endpoint": "https://jp-tok.appid.cloud.ibm.com/oauth/v4/3026b4fb-f9c5-4636-a632-535e7693c0ee/token",
...

2.token_endpointからトークンを入手する。
-u の値は、上記App ID資格情報のclientID、secretの値である。これが分かりずらい。
username、passwordは、昨日App IDにサインアップしてアカウント作成したユーザーのものを指定する。
実行結果から、id_tokenの値を入手して、tokenとかのシェル変数に格納しておく。

curl -X POST \
-H 'Content-Type: application/x-www-form-urlencoded' \
-u '39bdc2f7-a818-406e-97ad-a5436bde3c2a:OWIzYmQ1MGYtMzI2Yy00OTY4LWIyNTEtYjcyMDdhODIwYjNm' \
-d '[email protected]' -d 'password=password' -d 'grant_type=password' \
https://jp-tok.appid.cloud.ibm.com/oauth/v4/3026b4fb-f9c5-4636-a632-535e7693c0ee/token | python -m json.tool

(実行結果)
...
    "id_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6...",
...
# token=eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6...

3.API Gatewayにアクセスする準備が整った。先のRouteのURLに以下の通りアクセスする。まずは、JSONデータをPUTで格納するところからである。
x-ibm-client-id、x-ibm-client-secretは、先と同様App IDの資格情報である。
-d として、PUTで渡すjsonデータを指定している。
成功したら「{"result":"success"}」とメッセージが返ってくる。

# curl -X PUT \
-H 'x-ibm-client-id: 39bdc2f7-a818-406e-97ad-a5436bde3c2a' \
-H 'x-ibm-client-secret: OWIzYmQ1MGYtMzI2Yy00OTY4LWIyNTEtYjcyMDdhODIwYjNm' \
-H "authorization: Bearer $token" \
-d '{"name":"file1","text":"hello"}' \
https://gw.jp-tok.apigw.appdomain.cloud/api/e9a5eae550216a16d2ebfa5f97740d04bb30c9516495ea1d1ca5e8288b23ff30/micro

(実行結果)
{"result":"success"}

ちなみに、「{"status":401,"message":"Error: The token signature did not match any known JWK."}」というエラーメッセージが表示されるようだったら、それはtoken_idの出力をtoken変数に格納するときのコピペの場所を間違えている可能性が高い。
以下のサイトに文字列を張り付ければ、そのトークンがJWTとして正しいものか、なんとなく見当が付くはず。例えば、PAYLOAD:DATAにメアドが記載されていない場合、それはアクセストークンであり、間違い。
https://jwt.io/

4.GETでデータの取得もしてみよう。
URLの終端にパラメータ付ける形式にしたの失敗したなと思ったが、?name=file1でGETパラメータを指定しており、指定したファイルが見つかればその内容を返してくれる。

curl -X GET \
-H 'x-ibm-client-id: 39bdc2f7-a818-406e-97ad-a5436bde3c2a' \
-H 'x-ibm-client-secret: OWIzYmQ1MGYtMzI2Yy00OTY4LWIyNTEtYjcyMDdhODIwYjNm' \
-H "authorization: Bearer $token" \
https://gw.jp-tok.apigw.appdomain.cloud/api/e9a5eae550216a16d2ebfa5f97740d04bb30c9516495ea1d1ca5e8288b23ff30/micro?name=file1

(実行結果)
{"name":"file1","text":"hello"}

期待通りの動作である。

おわりに

API Gatewayの機能にもう少し踏み込むと、今回確認したAPIにOIDC等のユーザー認証を追加する、(非公式であるが)HTTPのAPIエンドポイントにHTTPSを提供する、といったものに加えて、APIのアクセス数をグラフで表示したり、単位時間あたり可能なアクセス数に上限を設けてバックエンドの負荷を抑えるといった機能がある。

APIをセッションステートフルにするためにアクセス毎に変わるセッションIDを発行するとか、バックエンドのAPIサーバーを複数登録してAPI Gatewayが自前でラウンドロビンをするといった機能は無い。まあ、タダで提供できるサービスの限界か。

しかし、せめてREST-APIサーバーにHTTPアクセス可能なソースIPをAPI Gatewayに限定するようフィルタリングしたいものだが、以下のページが嘘っぱちで正確なIPアドレスがわからないのは困る。TOKリージョンのリクエストが165.192.85.41からって、どこから来てんだか。
https://cloud.ibm.com/docs/virtual-router-appliance?topic=hardware-firewall-dedicated-ibm-cloud-ip-ranges