新鮮なアクセストークンを求めて(k6編)

こんにちは、SREの福谷です。
毎月投稿を目標にしようと考えていたのですが、早くもネタ切れの気配に怯えています。

あらすじ

  • OAuth 2.0のトークンリフレッシュに非対応なツールがある
  • 特にアクセストークンの有効期限が短い場合、アクセスできなくなる問題が発生する
  • k6では特に制約条件が厳しく、小細工が必要になるので対応してみました

HTTPでの限定的なアクセスを認可する仕組みとして、OAuth 2.0というものがあります。
これ自体についての説明は省略しますが、弊社のOPTiM Cloud IoT OS(以下 CIOS)でもOAuth 2.0(+OpenID Connect)を用いてAPI を保護しています。

アクセストークンはアクセスを認可されたことを示す文字列であり、漏洩すると第三者からの意図しないアクセスを許してしまいます。(よく合鍵に例えられますよね)
アクセストークンの暴露に備えた対策としては、失効できるようにする・有効期間を短くするといったことが考えられます。
今回の話題は、特に後者の「有効期間の短いアクセストークン」を扱う場合の注意点といったところです。

※あくまでも検証作業のためにシークレット情報をテストツールに渡していることがあります。通常のアプリケーション開発ではセキュリティ上不適切な場合があることにご注意ください。

リフレッシュトークン

有効期間が短いアクセストークンを採用する事により、仮に漏洩してしまった場合でも一切の操作なしで一定期間後には該当のトークンが失効することになります。

これは認可を行った側としては楽でよいのですが、認可を受けた側(アプリケーションサーバ等)は少しの間しかアクセストークンを使用できないので困ってしまいます。
どういうことかというと、アプリに自身の情報に対するアクセス権を認可したのにも関わらず しばらくすると動かなくなる(もしくは再度認可を要求される)ということが発生しかねません。

これでは具合が悪いので、リフレッシュトークンを用います。 リフレッシュトークンはアクセストークンを発行するためのトークンであり、一般的にはアクセストークンより有効期間が長いです。
2つのトークンを使い分けることにより、有効期間の短さによるセキュリティ面でのメリットを受けつつ 限定されたアクセスをアプリ等に認可することができます。

※リフレッシュトークン対応はOAuth 2.0的に必須ではないので、実装されていない場合もあります。

課題

CIOSのAPIは、前述の「有効期間が短いアクセストークン + リフレッシュトークン」によってセキュリティリスクを低減しています。
APIにアクセスするアプリを作る・利用する上ではあまり問題ないのですが、いくつかの開発者向けツールでリフレッシュトークン非対応のものがあり微妙に困る事がありました。

今回はk6におけるリフレッシュトークン対応について、どうしたか共有しようと思います。

k6

k6はJavaScriptで処理内容を記述できる負荷試験ツールです。
アクセストークンが短時間で失効する場合、テストが途中で失敗しだすので対策が必要でした。 テストが短時間ならアクセストークンを渡せば問題ないと思われるかもしれませんが、長期間持続することを前提とした負荷試験やCIなどに組み込んで自動化しようとすると 都度最新のアクセストークンを用意することがやはり必要になります。

公式サイトのサンプルでもリフレッシュを考慮しない場合のはありますが、アクセストークンの有効期限切れについては特に解決策を提供していません。

k6はGo製のJavasScriptランタイム(goja)でスクリプトを動作させており、Node.jsやブラウザのJavaScriptエンジンとは異なります。 ES2015/ES6互換となってはいるのですがPromiseが使えなかったり、requireが名前空間を識別してくれなかったり等、制限が多めです。
Node.jsのモジュールも物によっては読み込めるのですが、webpackによるバンドルが必要など手間が多く、既存のライブラリを用いてトークンのリフレッシュを実施することは困難でした。

リフレッシュトークンで最新のアクセストークンを都度取得できないと、以下のようにアクセスできなくなってしまいます。 https://cdn-ak.f.st-hatena.com/images/fotolife/o/optim-tech/20210330/20210330181045.png

※リフレッシュトークン等のテストに必要な情報は予め用意してあるものとします。

k6用httpクライアントライブラリをつくる

軽く調べた感触だと、同様の事例への対応策は見つかりませんでした。
そこで結論として、k6のスクリプト中でのみ使えるライブラリを作成することとしました。
何番煎じかはわからないですが、ある程度汎用的にしてあるので参考になるかもしれません。
k6-sample.jsからoauth2lib.jsを呼び出して使うイメージです。

k6-sample.js

import * as oauth2 from './path/to/oauth2lib.js'

let client = new oauth2.OAuth2Client() // 同一VUの繰り返し間で共有されるインスタンスを設定無しで初期化する

export const setup = () => {
  // 初回のアクセストークン取得
  let client = new oauth2.OAuth2Client(
    tokenEndpoint,
    clientID,
    clientSecret,
    refreshToken,
  )

  return client.getParams() // setup中に生成したインスタンスはVUステージまで保持できないので、データのみを渡す
}

export default (data) => {
  // 初回の繰り返し時、setup dataよりclientオブジェクトを復元
  if (__ITER === 0) client.setParams(data)

  // 常に期限の切れていないアクセストークンを付与してAPIにアクセスできる
  client.httpGet(protectedURL)
}

oauth2lib.js

import { check } from "k6"
import http from "k6/http"
import encoding from 'k6/encoding'

const tokenRefreshMarginSec = 10.0

export class OAuth2Client {

  /* class variables */
  // tokenEndpoint
  // clientID
  // clientSecret
  // refreshToken
  // accessToken
  // expiresAt

  constructor(tokenEndpoint, clientID, clientSecret, refreshToken) {
    // return empty object
    if (!tokenEndpoint && !clientID && !clientSecret && !refreshToken) {
      return
    }

    // set class variables
    this.tokenEndpoint = tokenEndpoint
    this.clientID = clientID
    this.clientSecret = clientSecret
    this.refreshToken = refreshToken

    // get first access token
    this.tokenRefresh()
  }

  tokenRefresh() {
    // exists token available
    if (this.expiresAt) {
      let expiredAtLimit = new Date(this.expiresAt - tokenRefreshMarginSec * 1000)
      if (Date.now() < expiredAtLimit.getTime()) {
        return
      }
    }

    // obtain new access_token
    let res = http.post(
      this.tokenEndpoint,
      `grant_type=refresh_token&refresh_token=${this.refreshToken}`,
      {
        headers: {
          "Content-Type": "application/x-www-form-urlencoded",
          "Authorization": `Basic ${encoding.b64encode(`${this.clientID}:${this.clientSecret}`)}`,
        }
      }
    )
    if (!check(res, {
      "resonse code was 200": (res) => res.status == 200,
    })) {
      throw new Error("error at token refresh :" + JSON.stringify(res))
    }
    this.accessToken = res.json("access_token")
    this.expiresAt = Date.now() + parseInt(res.json("expires_in")) * 1000
  }

  getParams() {
    this.tokenRefresh()
    return {
      tokenEndpoint: this.tokenEndpoint,
      clientID: this.clientID,
      clientSecret: this.clientSecret,
      refreshToken: this.refreshToken,
      accessToken: this.accessToken,
      expiresAt: this.expiresAt,
    }
  }

  setParams(data) {
    this.tokenEndpoint = data.tokenEndpoint
    this.clientID = data.clientID
    this.clientSecret = data.clientSecret
    this.refreshToken = data.refreshToken
    this.accessToken = data.accessToken
    this.expiresAt = data.expiresAt
  }

  // return latest access_token
  getToken() {
    this.tokenRefresh()
    return this.accessToken
  }

  // Authorization: Bearer <access_token>
  getAuthHeaders() {
    return { Authorization: `Bearer ${this.getToken()}` }
  }

  // HTTP reqest with latest access_token
  httpRequest(method, url, body, params = {}) {
    // set token
    if (!('headers' in params)) params.headers = this.getAuthHeaders()
    else params.headers.Authorization = `Bearer ${this.getToken()}`

    return http.request(method, url, body, params)
  }

  httpGet(url, params) {
    return this.httpRequest('GET', url, null, params)
  }
}

これで、常に期限内のアクセストークンを使用したAPIアクセスが可能になりました。
状況としては以下のようになったはずです。

https://cdn-ak.f.st-hatena.com/images/fotolife/o/optim-tech/20210329/20210329170158.png

コードの雑さはご容赦いただきたいところですが、テストツールという特殊な条件では このように自分で認証・認可を突破する必要がある場合もあります。
少しでも参考になれば幸いです。

※確認したk6のバージョンはv0.31.0となります。

終わりに

次回はPostman, cURL でのリフレッシュトークン対応について執筆予定です。


オプティムで一緒に働けるエンジニアを募集しています ! www.optim.co.jp

特にSRE, 技術的なプロダクトの改善に興味がある方は以下も御覧ください recruit.jobcan.jp