2026年7月11日

2026年7月11日

WordPressのHTTP APIでOAuth2トークンの取得と更新を実装する方法(期限管理・リフレッシュ・PKCE)

WordPress から OAuth 2.0 で保護された外部 API を呼ぶには、wp_remote_post() でトークンエンドポイントからアクセストークンを取得し、期限が切れる前(または 401 が返ったとき)にリフレッシュトークンで更新する処理を組み込みます。この記事では、サーバー間連携向けの「クライアントクレデンシャル」と、管理者の同意が必要な「認可コード(PKCE付き)」の2つの流れについて、トークンの保存場所、期限の管理、同時更新の防止、失敗時の扱いまでをコード付きで説明します。

外部サービスごとにエンドポイントのURL、使えるグラントタイプ、スコープ名、クライアント認証の方法は異なります。コード中の auth.example.com などは例なので、連携先の開発者向けドキュメントで実際の値を確認してから置き換えてください。

どちらのフローを使うか

  • クライアントクレデンシャル: サイト自体が1つのクライアントとして API を使う場合(自社サービスとのサーバー間連携など)。ユーザーの同意画面は無く、クライアントIDとシークレットだけでトークンを取得します。仕様上、この方式ではリフレッシュトークンを発行しないのが一般的で、期限が切れたら同じ方法で取り直します。
  • 認可コード: Google や会計サービスなど、特定のアカウントのデータを扱う場合。管理者が一度ブラウザーで連携先にログインして許可し、その後はリフレッシュトークンでアクセストークンを更新し続けます。現在のセキュリティのベストプラクティス(RFC 9700)では、サーバー側のアプリでも PKCE の併用が推奨されています。

認証情報と保存先の設計

クライアントIDとシークレットは wp-config.php の定数に置き、データベースやテーマのファイルには書きません。

// wp-config.php
define( 'MYAPI_CLIENT_ID', 'your-client-id' );
define( 'MYAPI_CLIENT_SECRET', 'your-client-secret' );

取得したトークンは、autoload を無効にしたオプションに保存します。トランジェントはオブジェクトキャッシュの削除などで消えることがあり、リフレッシュトークンを失うと管理者が連携をやり直す必要があるためです。オプションの値はデータベースに平文で入るため、データベースのバックアップの扱いにも注意してください。必要に応じて、wp-config.php に置いた鍵で暗号化してから保存する方法もあります。

以下のコードは、自作プラグインまたは mu-plugin に置く前提です。導入前にバックアップを取り、ステージング環境で試してください。

トークンエンドポイントを呼ぶ共通関数

<?php
const MYAPI_TOKEN_URL = 'https://auth.example.com/oauth/token';
const MYAPI_AUTH_URL  = 'https://auth.example.com/oauth/authorize';
const MYAPI_API_BASE  = 'https://api.example.com';
const MYAPI_OPTION    = 'myapi_oauth_tokens';

/**
 * トークンエンドポイントにPOSTし、結果を保存して返す。
 *
 * @return array|WP_Error
 */
function myapi_request_token( array $params ) {
	$response = wp_remote_post(
		MYAPI_TOKEN_URL,
		array(
			'timeout' => 15,
			'headers' => array(
				// クライアント認証(client_secret_basic)。連携先が本文での送信を求める場合は
				// client_id と client_secret を $params 側に入れる
				'Authorization' => 'Basic ' . base64_encode( rawurlencode( MYAPI_CLIENT_ID ) . ':' . rawurlencode( MYAPI_CLIENT_SECRET ) ),
				'Accept'        => 'application/json',
			),
			'body'    => $params, // 配列は application/x-www-form-urlencoded で送信される
		)
	);

	if ( is_wp_error( $response ) ) {
		return $response;
	}

	$code = (int) wp_remote_retrieve_response_code( $response );
	$data = json_decode( wp_remote_retrieve_body( $response ), true );

	if ( 200 !== $code || empty( $data['access_token'] ) ) {
		$error = ( is_array( $data ) && ! empty( $data['error'] ) ) ? sanitize_key( $data['error'] ) : 'http_' . $code;
		return new WP_Error( 'myapi_token_' . $error, 'トークンを取得できませんでした: ' . $error );
	}

	$old    = get_option( MYAPI_OPTION, array() );
	$tokens = array(
		'access_token'  => $data['access_token'],
		// expires_in が無い連携先もあるため、その場合の既定値は連携先の仕様に合わせる
		'expires_at'    => time() + ( isset( $data['expires_in'] ) ? (int) $data['expires_in'] : 3600 ),
		// 新しいリフレッシュトークンが返れば差し替え、返らなければ以前の値を残す
		'refresh_token' => $data['refresh_token'] ?? ( $old['refresh_token'] ?? '' ),
	);
	update_option( MYAPI_OPTION, $tokens, false );

	return $tokens;
}

ポイントは3つあります。1つ目は、HTTP のエラーとトークンの中身の両方を確認していることです。OAuth のエラーは多くの場合 400 番台の応答と、error 項目(invalid_client、invalid_grant など)を含む JSON で返ります。2つ目は、期限を「何秒後」ではなく「何時に切れるか(UNIX時刻)」で保存していることで、後からの判定が簡単になります。3つ目は、リフレッシュトークンのローテーション(更新のたびに新しい値が発行され、古い値が使えなくなる方式)に対応するため、返ってきたら差し替える作りにしていることです。

有効なアクセストークンを返す関数

期限の60秒前を過ぎたら更新し、同時に複数のリクエストが更新処理を走らせないよう簡易的なロックをかけます。ローテーション方式の連携先で2つのリクエストが同時に更新すると、片方が古いリフレッシュトークンを使って失敗し、最悪の場合は連携が切れるためです。

function myapi_lock( $acquire ) {
	if ( wp_using_ext_object_cache() ) {
		return $acquire ? wp_cache_add( 'refresh_lock', 1, 'myapi', 30 ) : wp_cache_delete( 'refresh_lock', 'myapi' );
	}
	// オブジェクトキャッシュが無い場合の簡易版(同時実行を減らすが、厳密な排他ではない)
	if ( ! $acquire ) {
		return delete_transient( 'myapi_refresh_lock' );
	}
	if ( get_transient( 'myapi_refresh_lock' ) ) {
		return false;
	}
	return set_transient( 'myapi_refresh_lock', 1, 30 );
}

/**
 * @return string|WP_Error
 */
function myapi_get_access_token( $force_refresh = false ) {
	$tokens = get_option( MYAPI_OPTION, array() );
	if ( ! $force_refresh && ! empty( $tokens['access_token'] ) && $tokens['expires_at'] - 60 > time() ) {
		return $tokens['access_token'];
	}

	if ( ! myapi_lock( true ) ) {
		return new WP_Error( 'myapi_busy', 'トークンを更新中です。少し待ってから再試行してください。' );
	}

	if ( ! empty( $tokens['refresh_token'] ) ) {
		$result = myapi_request_token(
			array(
				'grant_type'    => 'refresh_token',
				'refresh_token' => $tokens['refresh_token'],
			)
		);
	} else {
		$result = myapi_request_token(
			array(
				'grant_type' => 'client_credentials',
				'scope'      => 'read',
			)
		);
	}
	myapi_lock( false );

	if ( is_wp_error( $result ) ) {
		if ( 'myapi_token_invalid_grant' === $result->get_error_code() ) {
			// リフレッシュトークンが失効・取り消し済み。再連携が必要
			delete_option( MYAPI_OPTION );
		}
		return $result;
	}

	return $result['access_token'];
}

クライアントクレデンシャルだけを使う場合は、リフレッシュトークンが保存されないため、常に else 側で取り直す動きになります。

APIを呼び出し、401なら1回だけ更新して再試行する

期限内でも、連携先の都合でトークンが無効になることがあります。401 が返ったら一度だけ強制更新して再試行し、それでも失敗したらエラーとして返します。

function myapi_get( $path ) {
	for ( $attempt = 0; $attempt < 2; $attempt++ ) {
		$token = myapi_get_access_token( $attempt > 0 );
		if ( is_wp_error( $token ) ) {
			return $token;
		}

		$response = wp_remote_get(
			MYAPI_API_BASE . $path,
			array(
				'timeout' => 10,
				'headers' => array(
					'Authorization' => 'Bearer ' . $token,
					'Accept'        => 'application/json',
				),
			)
		);
		if ( is_wp_error( $response ) ) {
			return $response;
		}
		if ( 401 !== (int) wp_remote_retrieve_response_code( $response ) ) {
			break;
		}
	}

	$code = (int) wp_remote_retrieve_response_code( $response );
	if ( 200 !== $code ) {
		return new WP_Error( 'myapi_http', sprintf( 'APIがHTTP %d を返しました。', $code ) );
	}
	return json_decode( wp_remote_retrieve_body( $response ), true );
}

ループを2回までにしているのは、認証情報そのものが間違っているときに、更新と再試行を無限に繰り返さないためです。

認可コードフロー(PKCE付き)で最初のトークンを得る

管理者が「連携する」ボタンを押すと連携先の同意画面へ移動し、許可後に WordPress へ戻ってきたところでコードをトークンに交換します。戻り先(リダイレクトURI)は連携先の管理画面に登録した値と一字一句同じである必要があります。

function myapi_redirect_uri() {
	return admin_url( 'admin-post.php?action=myapi_callback' );
}

// 設定画面に置くボタン(管理画面の出力内で使う)
function myapi_connect_button() {
	?>
	<form method="post" action="<?php echo esc_url( admin_url( 'admin-post.php' ) ); ?>">
		<input type="hidden" name="action" value="myapi_connect">
		<?php wp_nonce_field( 'myapi_connect' ); ?>
		<?php submit_button( '外部サービスと連携する' ); ?>
	</form>
	<?php
}

add_action( 'admin_post_myapi_connect', function () {
	if ( ! current_user_can( 'manage_options' ) ) {
		wp_die( '権限がありません。', 403 );
	}
	check_admin_referer( 'myapi_connect' );

	$state    = wp_generate_password( 32, false );
	$verifier = wp_generate_password( 64, false ); // PKCEのcode_verifier(43〜128文字)
	set_transient(
		'myapi_oauth_' . get_current_user_id(),
		array( 'state' => $state, 'verifier' => $verifier ),
		10 * MINUTE_IN_SECONDS
	);

	$challenge = rtrim( strtr( base64_encode( hash( 'sha256', $verifier, true ) ), '+/', '-_' ), '=' );
	$params    = array(
		'response_type'         => 'code',
		'client_id'             => MYAPI_CLIENT_ID,
		'redirect_uri'          => myapi_redirect_uri(),
		'scope'                 => 'read',
		'state'                 => $state,
		'code_challenge'        => $challenge,
		'code_challenge_method' => 'S256',
	);

	// 移動先は定数で決めた連携先なので wp_redirect を使う
	wp_redirect( add_query_arg( array_map( 'rawurlencode', $params ), MYAPI_AUTH_URL ) );
	exit;
} );

add_action( 'admin_post_myapi_callback', function () {
	if ( ! current_user_can( 'manage_options' ) ) {
		wp_die( '権限がありません。', 403 );
	}

	$key   = 'myapi_oauth_' . get_current_user_id();
	$saved = get_transient( $key );
	delete_transient( $key ); // stateは1回限り

	$state = isset( $_GET['state'] ) ? sanitize_text_field( wp_unslash( $_GET['state'] ) ) : '';
	$code  = isset( $_GET['code'] ) ? sanitize_text_field( wp_unslash( $_GET['code'] ) ) : '';

	if ( ! is_array( $saved ) || '' === $state || '' === $code || ! hash_equals( $saved['state'], $state ) ) {
		wp_die( '連携の確認に失敗しました。設定画面から最初からやり直してください。', 400 );
	}

	$result = myapi_request_token(
		array(
			'grant_type'    => 'authorization_code',
			'code'          => $code,
			'redirect_uri'  => myapi_redirect_uri(),
			'code_verifier' => $saved['verifier'],
		)
	);

	$status = is_wp_error( $result ) ? 'error' : 'connected';
	wp_safe_redirect( add_query_arg( 'myapi', $status, admin_url( 'options-general.php?page=myapi' ) ) );
	exit;
} );

このコードで守っていること

  • 権限と nonce: 連携の開始は manage_options を持つユーザーだけに限定し、ボタンの送信は check_admin_referer() で nonce を確認します。
  • state: 同意画面から戻ってきたリクエストが、このユーザー自身が開始したものかを確かめる値です。ランダムに作って保存し、戻ってきた値と hash_equals() で比べ、1回使ったら削除します。これを省くと、攻撃者のアカウントを管理者のサイトに連携させるような攻撃(CSRF)を防げません。
  • PKCE: code_verifier から作った code_challenge を最初に送り、コード交換時に元の値を送ります。途中でコードが盗まれても、元の値を知らなければトークンに交換できません。
  • リダイレクト先: 外部へ移動する wp_redirect() は、定数で決めた連携先のURLにだけ使い、利用者の入力から作ったURLには使いません。戻り先の管理画面へは wp_safe_redirect() を使います。

戻り先の options-general.php?page=myapi は、自作の設定ページのスラッグに置き換えてください。

運用時の注意と確認方法

  • 再連携が必要になったら知らせる: invalid_grant はリフレッシュトークンの失効や、連携先での許可の取り消しで起きます。トークンを削除した後、管理画面のお知らせ(admin_notices)などで再連携を促すと、気づかないまま連携が止まる事態を防げます。
  • ログ: 失敗時はエラーコードとHTTPステータスだけを記録し、アクセストークン、リフレッシュトークン、クライアントシークレットはログに出しません。
  • 確認方法: 保存されたオプションの expires_at を一時的に過去の時刻へ書き換えてから myapi_get() を呼び、更新が走って新しい期限が保存されるかを確かめます。リフレッシュトークンを連携先で取り消して、invalid_grant の扱いも試しておくと安心です。
  • 連携の解除: プラグインの停止や削除の際はオプションを削除し、連携先がトークンの取り消し(revocation)エンドポイントを提供していればそれも呼びます。
  • 仕様の変化: 連携先のトークンの有効期限、ローテーションの有無、スコープ名は変更されることがあります(2026年10月現在の各サービスの仕様も、更新が続いています)。連携先の開発者向けドキュメントと変更履歴を定期的に確認してください。

まとめ

  • トークンは wp_remote_post() でトークンエンドポイントへフォーム形式で送り、HTTPステータスと error 項目の両方を確認する。
  • 期限は「切れる時刻」で保存し、少し前に更新する。リフレッシュトークンは返ってきたら差し替える。
  • 同時更新を避けるロックをかけ、401 のときは1回だけ強制更新して再試行する。
  • 認可コードフローでは、権限チェック・nonce・state・PKCE をそろえ、リダイレクトURIは登録値と一致させる。
  • シークレットは wp-config.php に置き、トークンは autoload 無効のオプションに保存して、ログには出さない。

お気軽にご相談ください

お見積りへ お問い合わせへ
▲