🪪

ServiceNow のサーバースクリプトから AWS SigV4 署名付きリクエストを生成するクラス実装

に公開

TL;DR

  • ServiceNow サーバースクリプトから AWS API (Lambda/S3/SES 等) を直接呼ぶための再利用可能クラス X_AWSSigV4RequestBuilder を実装する。
  • 必須項目 (method / service / region / endpoint / credentialId) を渡すだけで SigV4 署名済み sn_ws.RESTMessageV2 を取得または即時実行できる。

はじめに

ServiceNow のサーバースクリプトから AWS の REST API (S3, Lambda, SES など) を直接呼びたいとき、AWS Signature Version 4 (以降 AWS SigV4) の署名生成が課題になります。
本記事では単一クラス X_AWSSigV4RequestBuilder を使い、再利用しやすい形で sn_ws.RESTMessageV2 に署名ヘッダーを組み込み、即時実行まで行う実装例を紹介します。

背景と課題

今回 AWS Lambda 関数 URL を AWS_IAM 認証を用いて ServiceNow のサーバースクリプトから呼び出すニーズがあり、実装に取り掛かりました。

ServiceNow のドキュメント Amazon 署名ベースのカスタムアルゴリズムの設定
には、署名アルゴリズムを追加方法、アクセスキー ID とシークレットアクセスキーの保存方法、フローデザイナーの REST アクションで SigV4 署名を行う方法が記載されています。
しかし、今回実現したかったサーバースクリプト (Script Include やビジネスルール) から直接呼び出す場合の具体的な手順は記載されておらず、実装に悩みました。
今後、同様のニーズを抱える ServiceNow 開発者の参考になればと考え、記事にまとめました。

実装のゴール

  • 必須パラメータを渡すだけで手軽に署名済み RESTMessageV2 を取得もしくは実行できる

前提

  • ServiceNow Zurich バージョンで動作を確認
  • 事前に署名アルゴリズム AWS Signature Version 4 を作成済み
  • 事前にアクセスキー ID とシークレットアクセスキーを認証情報として保存済み

全体像

コード

S3 を想定した記述を含みますが、Lambda への GET/POST でのみ動作確認しています。

コード
/**
 * AWS Signature Version 4 認証付き RESTMessageV2 ビルダー
 */

var X_AWSSigV4RequestBuilder = Class.create();
X_AWSSigV4RequestBuilder.prototype = {
  /**
   * @param {Object} params
   * @param {sn_ws.RESTMessageV2} params.restMessage - RESTMessageV2 オブジェクト
   * @param {String} params.method - HTTP メソッド (GET, POST, PUT, DELETE)
   * @param {String} params.service - AWS サービス名 (例: "lambda", "s3")
   * @param {String} params.region - AWS リージョン (例: "ap-northeast-1")
   * @param {String} params.endpoint - エンドポイント URL (例: "https://<YOUR_LAMBDA_URL>.lambda-url.ap-northeast-1.on.aws/")
   * @param {String} params.credentialId - 認証情報の sys_id
   * @param {String} [params.content] - リクエストボディ(省略時は空文字列)
   * @param {Object} [params.queryParams] - クエリパラメータのキー・バリューオブジェクト
   */
  initialize: function (params) {
    this.params = params || {};
    this.restMessage = this.params.restMessage || new sn_ws.RESTMessageV2();
    this.method = this.params.method.toUpperCase();
    this.service = this.params.service;
    this.region = this.params.region;
    this.endpoint = this.params.endpoint;
    this.host = this._parseUrl(this.endpoint).host;
    this.credentialId = this.params.credentialId;
    this.content = this.params.content || "";
    this.queryParams = this.params.queryParams || {};

    this.httpRequestData = null;
    this.signature = null;

    this._isJsonBody = false; // 署名後に Content-Type を追加するためフラグ保持
  },

  /**
   * RESTMessageV2 オブジェクトをビルドして返します。
   * @return {sn_ws.RESTMessageV2} 署名付き RESTMessageV2 オブジェクト
   */
  build: function () {
    // 1. パラメータ検証
    this._validateParams();

    // 2. HttpRequestData オブジェクトを構築
    this._buildHttpRequestData();

    // 3. 署名付きリクエストを生成
    this._generateSignature();

    // 4. RESTMessageV2 を構築
    this._buildRestMessage();

    return this.restMessage;
  },

  /**
   * 即時実行
   * @return {sn_ws.RESTResponseV2} RESTResponseV2 応答オブジェクト
   */
  execute: function () {
    var restMessage = this.build();
    var response = restMessage.execute();
    return response;
  },

  /**
   * @private
   * URLを解析してホストとパスを抽出します。
   * @param {String} url - 解析するURL
   * @returns {Object} ホストとパスを含むオブジェクト e.g., { host: "example.com", path: "/api" }
   */
  _parseUrl: function (url) {
    var match = url.match(/^(https?:\/\/)?([^\/]+)(\/.*)?$/);
    if (!match) {
      throw new Error("Invalid URL format: " + url);
    }

    return {
      host: match[2],
      path: match[3] || "/",
    };
  },

  /**
   * @private
   * 必須パラメータの検証
   * @throws {Error} 必須パラメータが不足している場合
   */
  _validateParams: function () {
    if (!this.method) {
      throw new Error("パラメータ method が指定されていません。");
    }
    if (!this.service) {
      throw new Error("パラメータ service が指定されていません。");
    }
    if (!this.region) {
      throw new Error("パラメータ region が指定されていません。");
    }
    if (!this.endpoint) {
      throw new Error("パラメータ endpoint が指定されていません。");
    }
    if (!this.credentialId) {
      throw new Error("パラメータ credentialId が指定されていません。");
    }
  },

  /**
   * @private
   * HttpRequestData オブジェクトを構築します。
   */
  _buildHttpRequestData: function () {
    this.httpRequestData = new sn_auth.HttpRequestData();
    this.httpRequestData.setHttpMethod(this.method);
    this.httpRequestData.setService(this.service);
    this.httpRequestData.setRegion(this.region);
    this.httpRequestData.setEndpoint(this.endpoint);
    this.httpRequestData.setHost(this.host);

    // クエリパラメータ設定
    var queryKeys = Object.keys(this.queryParams);
    if (queryKeys.length > 0) {
      queryKeys.sort();
      for (var i = 0; i < queryKeys.length; i++) {
        var qKey = queryKeys[i];
        var rawVal = this.queryParams[qKey];
        // 値が undefined / null の場合は空文字列扱い
        var qVal = rawVal === undefined || rawVal === null ? "" : String(rawVal);
        this.httpRequestData.addQueryParam(qKey, qVal);
      }
    }

    // ボディ設定 (POST / PUT の場合のみ)
    if (this.method === "POST" || this.method === "PUT") {
      var body = this.content;

      if (body === null || body === undefined) {
        // null / undefined は空文字列
        body = "";
      } else if (typeof body === "object") {
        // オブジェクトは JSON 文字列化
        try {
          body = JSON.stringify(body);
          this._isJsonBody = true;
        } catch (e) {
          throw new Error("JSON stringify に失敗しました: " + e.message);
        }
      } else {
        // その他は文字列化
        body = String(body);
        // 既に JSON 文字列っぽい場合 (単純判定) は application/json を付与
        if ((body.charAt(0) === "{" && body.charAt(body.length - 1) === "}") || (body.charAt(0) === "[" && body.charAt(body.length - 1) === "]")) {
          this._isJsonBody = true;
        }
      }

      this.content = body;
      this.httpRequestData.setContent(body);
    }

    // S3の場合、x-amz-content-sha256 ヘッダーを追加
    if (this.service === "s3") {
      var sha256 = new sn_auth.SHA256();
      var payloadHash =
        this.content && this.content.length > 0 ? sha256.hashString(this.content) : "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"; // 空のペイロードのSHA256ハッシュ
      this.httpRequestData.addHeader("x-amz-content-sha256", payloadHash);
    }
  },

  /**
   * @private
   * 署名付きリクエストを生成します。
   */
  _generateSignature: function () {
    // 認証情報を取得
    var credential = new sn_cc.StandardCredentialsProvider().getAuthCredentialByID(this.credentialId);
    // RequestAuthAPIオブジェクトを作成し、リクエストに署名します
    var signingAPI = new sn_auth.RequestAuthAPI(this.httpRequestData, credential);
    this.signature = signingAPI.generateAuth();
  },

  /**
   * @private
   * RESTMessageV2 オブジェクトを構築します。
   */
  _buildRestMessage: function () {
    this.restMessage.setHttpMethod(this.method);
    this.restMessage.setEndpoint(this.endpoint);

    // クエリパラメータ設定
    var queryMap = this.httpRequestData.getQueryParamMap();
    for (var key in queryMap) {
      this.restMessage.setQueryParameter(key, queryMap[key]);
    }

    // ボディ設定
    this.restMessage.setRequestBody(this.httpRequestData.getContent());

    // ヘッダー設定
    var headerMap = this.signature.getHeaderMap();
    for (var hKey in headerMap) {
      this.restMessage.setRequestHeader(hKey, headerMap[hKey]);
    }

    // 署名後に Content-Type を追加(署名対象ヘッダーに含めない)
    this.restMessage.setRequestHeader("Content-Type", this._isJsonBody ? "application/json" : "text/plain");
  },

  type: "X_AWSSigV4RequestBuilder",
};

使用方法

GET

try {
  var builder = new X_AWSSigV4RequestBuilder({
    method: "GET",
    service: "lambda",
    region: "ap-northeast-1",
    endpoint: "https://<YOUR_LAMBDA_URL>.lambda-url.ap-northeast-1.on.aws/",
    credentialId: "<your_cred_sys_id>",
    queryParams: { key: "value" },
  });
  var restMessage = builder.build();
  var response = restMessage.execute();
  gs.print("Response Status: " + response.getStatusCode());
  gs.print("Response Body: " + response.getBody());
} catch (e) {
  gs.print("Error: " + e.message);
}

POST

try {
  var builder = new X_AWSSigV4RequestBuilder({
    method: "POST",
    service: "lambda",
    region: "ap-northeast-1",
    endpoint: "https://<YOUR_LAMBDA_URL>.lambda-url.ap-northeast-1.on.aws/",
    credentialId: "<your_cred_sys_id>",
    content: { key: "value" }
  });
  var response = builder.execute(); // build + execute 一括
  gs.print("Response Status: " + response.getStatusCode());
  gs.print("Response Body: " + response.getBody());
} catch (e) {
  gs.print("Error: " + e.message);
}

参考リンク

Discussion