APIの認証処理について

XG APIを利用する際には、Authorization: Bearer ヘッダーに認証トークンを指定してリクエストを行います。

この認証トークンは、JWT (JSON Web Token)形式で提供され、情報の完全性と送信元を確認できるように設計されています。

JWTは、JSONデータ構造をコンパクトでURLセーフな方法で表現するための規格で、JWS (JSON Web Signature) を使用して

その情報をデジタル署名またはMAC(Message Authentication Code)により保護します。

これにより、データが改ざんされることなく安全に送受信され、かつ、送信元が正当なものであることを保証できます。

JWTの作成手順

JWTは次の手順で作成します。

BASE64(UTF8(ヘッダー)) + '.' + BASE64(UTF8(ペイロード)) + '.' + BASE64(署名)

  • ヘッダー

    {
      "alg": "EdDSA",
      "typ": "JWT",
      "kid": "事前に登録した公開鍵の識別ID"
    }
    
  • ペイロード

    {
      "xgpi": "XG ProjectID",
      "xgai": "XG AppID",
      "xg_hash": "URLとRequestBodyを元に作成したハッシュ値",
      "iat": "トークンの発行日時",
      "exp": "トークンの有効期限"
    }
    

xg_hash は URLとRequestBodyを元に作成します。

ハッシュ値を作成する場合、RequestBodyの末尾の改行は除外して作成してください。

以下は Go のサンプルコードです
baseString := fmt.Sprintf("%s\n\n%s\n", apiPath, strings.TrimRight(string(requestBody), "\r\n"))
hasher := sha256.New()
hasher.Write([]byte(baseString))
xgHash := hex.EncodeToString(hasher.Sum(nil))

注意

  • iatは現在時刻のUNIXタイムスタンプを指定してください。

  • expの値は iatの値から60秒以内 に設定してください。
    (XG側でセキュリティとしてトークンの有効期限を60秒にしています)

JWT生成とXG API通信のイメージ

サンプルコード

以下は、PHP言語を用いて認証トークンを作成し、リクエストするためのサンプルコードです。
実行には、 opensslsodium が必要です。
php -m | grep -E '(openssl|sodium)'
openssl
sodium
このコードを実行する前に、以下のコマンドでライブラリをインストールしてください。
composer install
composer.json
{
    "autoload": {
        "psr-4": {
            "Sample\\": "sample/"
        }
    },
    "require": {
        "guzzlehttp/guzzle": "^7.8",
        "ramsey/uuid": "^4.7",
        "firebase/php-jwt": "^6.11"
    }
}
sample/Xgapi/XGAPI.php
<?php
namespace Sample\Xgapi;

use GuzzleHttp\Client;
use Firebase\JWT\JWT;

class XGAPI
{
    private $host;
    private $xgProjectId;
    private $xgApplicationId;
    private $kid;
    private $privateKey;

    /**
     * コンストラクタ
     * @param string $host         APIのホスト
     * @param string $projectId    XG ProjectID
     * @param string $applictionId XG AppID
     * @param string $kid          秘密鍵と公開鍵のペアのID
     * @param string $keyPath      秘密鍵のディレクトリ
     * @return void
     */
    public function __construct(string $host, string $projectId, string $applictionId, string $kid, string $keyPath)
    {
        // $hostの末尾の/があれば削除する
        if (substr($host, -1) == '/') {
            $host = substr($host, 0, -1);
        }

        $this->host = $host;
        $this->xgProjectId = $projectId;
        $this->xgApplicationId = $applictionId;
        $this->kid = $kid;

        $this->privateKey = $this->_loadPrivateKey($keyPath . '/private.pem');
    }

    /**
     * 秘密鍵を読み込む
     * @param string $filename
     * @return string sodium形式の秘密鍵
     */
    private function _loadPrivateKey(string $filename): string
    {
        // PEM(PKCS#8)秘密鍵ファイルの読み込み
        $privateKey = file_get_contents($filename);

        // 秘密鍵を利用して公開鍵を取得する
        $publicKey = openssl_pkey_get_details(openssl_get_privatekey($privateKey))['key'];

        // sodium形式に変換
        $pattern = '/-----BEGIN (?:PRIVATE|PUBLIC) KEY-----(.*)-----END (?:PRIVATE|PUBLIC) KEY-----/s';
        array_map(function(&$key) use($pattern) {
            if (preg_match($pattern, $key, $matches)) {
                $key = substr(base64_decode($matches[1]), -32);
            }
        }, [&$privateKey, &$publicKey]);
        $sodiumKey = base64_encode(sodium_crypto_box_keypair_from_secretkey_and_publickey($privateKey, $publicKey));

        return $sodiumKey;
    }

    /**
     * リクエスト送信
     * @param string $method (GET, POST, PUT, DELETE) リクエストメソッド
     * @param string $apiPath (ex. /user/v1/users) APIのパス
     * @param string $requestBody (ex. {"test":123}) リクエストボディーのJSON文字列
     * @return \Psr\Http\Message\ResponseInterface
     */
    public function request(string $method, string $apiPath, string $requestBody): \Psr\Http\Message\ResponseInterface
    {
        $baseString = sprintf("%s%s\n\n%s\n", $this->host, $apiPath, rtrim($requestBody, "\r\n"));
        $xgHash = hash('sha256', $baseString);

        $now = time();

        $payload = [
            'xgpi'    => $this->xgProjectId,
            'xgai'    => $this->xgApplicationId,
            'xg_hash' => $xgHash,
            'iat'     => $now,     //現在の日時
            'exp'     => $now + 30 //トークンの有効期限(セキュリティの観点で60秒以上はエラーになります)
        ];
        $jwt = JWT::encode($payload, $this->privateKey, 'EdDSA', $this->kid);

        $client = new Client();

        $response = $client->request($method, $this->host . $apiPath, [
           'headers' => [
                'Authorization' => 'Bearer ' . $jwt,
                'Content-Type' => 'application/json',
            ],
            'body' => $requestBody,
        ]);
        return $response;
    }
}
jwt_sample.php
<?php
/**
 *  XG APIのサンプルコード(JWT認証)
 *
 *  PHP8 で動作確認しています
 *
 *  このコードを実行する前に、以下のコマンドでライブラリをインストールしてください
 *   composer install
 */

require_once 'vendor/autoload.php';

use Sample\Xgapi\XGAPI;
use GuzzleHttp\Exception\BadResponseException;
use Ramsey\Uuid\Uuid;

function errorResponse($response) {
    $j = json_decode($response);
    if (is_null($j)) {
        // JSON解析エラーの処理
        throw new \Exception(sprintf("JSON Decode error: %s", $response));
    }
    if (isset($j->errors) && count($j->errors) > 0) {
        throw new \Exception(sprintf("エラーが発生しました: %s", $j->errors[0]->reason));
    }
    throw new \Exception("不明なエラーが発生しました");
}

function main() {
    // 初期設定
    $xgProjectId = "xg_sample";             // XG ProjectID
    $xgAppId = "dev";                       // XG AppID
    $kid = "sample_kid";                    // XGに登録した公開鍵の識別ID
    $host = "http://localhost";             // APIのホスト
    $keyPath = "./path/to/";                // 秘密鍵(private.pem)のディレクトリ

    // APIのインスタンスを生成
    $keyPath = realpath($keyPath);
    $xgApi = new XGAPI($host, $xgProjectId, $xgAppId,  $kid, $keyPath);

    //リクエスト
    $method      = 'POST';
    $path        = '/user/v1/users';
    $body        = "{}";
    try {
        $response = $xgApi->request($method, $path, $body);
    } catch (\Throwable $e) {
        error_log($e->getMessage());
    }

    //レスポンス結果
    echo "HttpStatus="   . $response->getStatusCode() . "\n";
    echo "ResponseBody=" . $response->getBody() . "\n";
}

main();
以下は、Go言語を用いて認証トークンを作成し、リクエストするためのサンプルコードです。
このコードを実行する前に、以下のコマンドでライブラリをインストールしてください。
go mod download
go.mod
module sample

go 1.21

require (
	github.com/gofrs/uuid v4.4.0+incompatible
	github.com/golang-jwt/jwt/v5 v5.2.0
)
xgapi/xgapi.go
package xgapi

import (
	"bytes"
	"crypto/sha256"
	"encoding/hex"
	"fmt"
	"log"
	"net/http"
	"os"
	"strings"
	"time"

	"github.com/golang-jwt/jwt/v5"
)

// APIエンドポイントへのリクエスト用の設定情報を持つ構造体
type XGAPI struct {
	signingMethod jwt.SigningMethod
	privateKey    []byte
	projectId     string
	appID         string
	kid           string
	host          string
}

// XGAPIと通信処理をするためのインスタンスを作成
func NewXGAPI(host string, projectId string, appId string, kid string, keyPath string) *XGAPI {
	privateKeyBytes, err := os.ReadFile(keyPath + "/private.pem")
	if err != nil {
		log.Fatalf("Failed to read private key: %v", err)
	}

	// host末尾の/ を削除
	host = strings.TrimSuffix(host, "/")

	return &XGAPI{
		signingMethod: jwt.SigningMethodEdDSA, // JWTの署名方式
		privateKey:    privateKeyBytes,        // 秘密鍵
		projectId:     projectId,              // XG ProjectID
		appId:         appId,                  // XG AppID
		kid:           kid,                    // 事前に登録した公開鍵の識別ID
		host:          host,                   // ベースとなるAPIのURL
	}
}

// JWTトークンの生成
func (api *XGAPI) createJWTToken(apiPath, body string) (string, error) {
	// ハッシュの生成 ( bodyの末尾の改行を削除する必要があります)
	baseString := fmt.Sprintf("%s%s\n\n%s\n", api.host, apiPath, strings.TrimRight(string(body), "\r\n"))

	hasher := sha256.New()
	hasher.Write([]byte(baseString))
	xgHash := hex.EncodeToString(hasher.Sum(nil))

	// トークンの生成
	now := time.Now()
	token := jwt.NewWithClaims(api.signingMethod, jwt.MapClaims{
		"xgpi":    api.projectID,
		"xgai":    api.appID,
		"xg_hash": xgHash,
		"iat":     now.Unix(),                       // トークンの発行時刻(現在時刻)
		"exp":     now.Add(time.Second * 30).Unix(), // トークンの有効期限(セキュリティの観点で60秒以上はエラーになります)
	})

	token.Header["kid"] = api.kid

	// トークンの署名
	parsedPrivateKey, err := jwt.ParseEdPrivateKeyFromPEM(api.privateKey)
	if err != nil {
		return "", err
	}

	return token.SignedString(parsedPrivateKey)
}

// リクエストの送信
func (api *XGAPI) SendRequest(method string, path string, body string) (*http.Response, error) {
	// トークンの生成
	jwtToken, err := api.createJWTToken(path, body)
	if err != nil {
		return nil, err
	}

	// リクエストの作成
	url := api.host + path
	req, err := http.NewRequest(method, url, bytes.NewReader([]byte(body)))
	if err != nil {
		return nil, err
	}

	// トークンのセット
	req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", jwtToken))

	req.Header.Set("Content-Type", "application/json")

	// リクエストの送信
	return http.DefaultClient.Do(req)
}
jwt_sample.go
package main

import (
	"encoding/json"
	"fmt"
	"io"
	"log"
	"net/http"
	"sample/xgapi"
)

type XgErrors struct {
	Reason string `json:"reason"`
}

type ErrorResponse struct {
	Type   string     `json:"type"`
	Status int        `json:"status"` // 追加: ステータスコードもキャプチャする。
	Errors []XgErrors `json:"errors"` // Typo を修正: "erorrs" -> "errors"
	Title  string     `json:"title"`
}

func errorResponse(bodyBytes []byte) error {
	errRes := ErrorResponse{}
	if err := json.Unmarshal(bodyBytes, &errRes); err != nil {
		// JSON解析エラーの処理
		log.Printf("JSON Unmarshal error: %v", err)
		return err
	}
	if len(errRes.Errors) > 0 {
		return fmt.Errorf("エラーが発生しました: %v", errRes.Errors[0].Reason)
	}
	return fmt.Errorf("不明なエラーが発生しました")
}

func main() {
	// 初期設定
	projectId := "xg_sample"                   // XG ProjectID
	appId     := "dev"                         // XG AppID
	kid       := "sample_kid"                  // XGに登録した公開鍵の識別ID
	host      := "http://localhost"            // APIのホスト
	keyPath   := "./path/to/"                  // 秘密鍵(private.pem)のディレクトリ

	// APIのインスタンスを作成
	xgApi := xgapi.NewXGAPI(host, projectId, appId, kid, keyPath)

	// リクエストの送信
	method := http.MethodPost  // HTTPメソッドの種類
	path   := "/user/v1/users" // APIのパス
	body   := "{}"             // リクエストボディ

	response, err := xgApi.SendRequest(method, path, body)
	if err != nil {
		panic(err)
	}
	defer response.Body.Close()

	// responseを確認
	fmt.Printf("HTTP status: %v\n", response.StatusCode)

	bodyBytes, err := io.ReadAll(response.Body)
	if err != nil {
		panic(err)
	}

	fmt.Printf("HTTP body: %s\n", string(bodyBytes))
}