0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

OAuth 1.0aの署名で3時間ハマった話:HMAC-SHA1とHMAC-SHA256の罠(GAS × X API)

0
Posted at

はじめに

Google Apps Script(GAS)からX API v2を使ってツイート投稿機能を実装したとき、認証テスト(GET /users/me)は通るのにPOST /tweetsだけが401で弾かれるという現象に3時間ハマりました。

原因は OAuth 1.0a の署名生成ロジックにあった「HMAC-SHA1宣言なのにSHA256で計算していた」という凡ミスです。本記事では同じ罠にハマる人を減らすため、原因・GAS APIの紛らわしい点・正しい実装・切り分け手順をまとめます。

環境情報

項目 バージョン / 値
ランタイム Google Apps Script(V8)
API X API v2(旧Twitter API)
認証方式 OAuth 1.0a User Context
エンドポイント(成功) GET https://api.twitter.com/2/users/me
エンドポイント(失敗) POST https://api.twitter.com/2/tweets
利用関数 Utilities.computeHmacSignature / Utilities.computeHmacSha256Signature

症状

  • GET /2/users/me → 200 OK(認証通る)
  • POST /2/tweets → 401 Unauthorized(Unauthorized メッセージのみ)
  • API Key / API Secret / Access Token / Access Token Secret はいずれも正しい
  • タイムスタンプ・nonce・パーセントエンコーディングも仕様通り

「認証ヘッダのどこかが間違っている」のは確実だが、GETは通るので資格情報自体は生きている――ここで思考が止まりやすいポイントです。

原因:宣言した署名方式と実際の計算アルゴリズムの不一致

OAuth 1.0a の Authorization ヘッダには以下のように署名方式を宣言します。

oauth_signature_method="HMAC-SHA1"

ところが署名計算側のコードでこうなっていました。

// NG: 宣言は HMAC-SHA1 なのに SHA256 で計算している
const signature = Utilities.computeHmacSha256Signature(
  signatureBaseString,
  signingKey
);

X APIサーバーは「HMAC-SHA1で署名しているはず」として検証するため、SHA256で生成した署名は永遠に一致しません。結果として401が返ります。

なぜGETは通ってPOSTで落ちたのか

GET検証で偶然通ったわけではなく、実装初期は別のエンドポイント用に正しいコードを書いていて、ツイート投稿用に書き換えた際にコピペで computeHmacSha256Signature 版を使ってしまったというのが真相でした。

OAuth 1.0a の仕様上、署名方式の不一致はメソッドに関係なく401になります。ただし切り分け時に「GETは通るからコードは正しい」と誤認しやすいので注意が必要です。

GAS APIの紛らわしさ

GASの Utilities には HMAC 系関数が複数あり、名前と挙動が混乱を招きます。

関数 アルゴリズム 第2引数のアルゴリズム指定
Utilities.computeHmacSignature(algorithm, value, key) 引数で選択(SHA1, SHA256, MD5など) Utilities.MacAlgorithm.HMAC_SHA_1
Utilities.computeHmacSha256Signature(value, key) SHA256固定 なし
Utilities.computeDigest(algorithm, value) ハッシュ関数(HMACではない) -

computeHmacSignaturecomputeHmacSha256Signature が別物であることを忘れて補完候補から雑に選ぶと、今回のような事故が起きます。

正しい実装

function generateOAuthSignature(method, url, params, consumerSecret, tokenSecret) {
  // 1. パラメータをアルファベット順にソート&連結
  const sortedParams = Object.keys(params)
    .sort()
    .map(k => `${encodeRFC3986(k)}=${encodeRFC3986(params[k])}`)
    .join('&');

  // 2. シグネチャベースストリングの構築
  const signatureBaseString = [
    method.toUpperCase(),
    encodeRFC3986(url),
    encodeRFC3986(sortedParams)
  ].join('&');

  // 3. 署名キー
  const signingKey =
    `${encodeRFC3986(consumerSecret)}&${encodeRFC3986(tokenSecret || '')}`;

  // 4. ★ HMAC-SHA1 で署名(ここが重要)
  const signatureBytes = Utilities.computeHmacSignature(
    Utilities.MacAlgorithm.HMAC_SHA_1,
    signatureBaseString,
    signingKey
  );

  return Utilities.base64Encode(signatureBytes);
}

// RFC 3986 準拠のパーセントエンコード
function encodeRFC3986(str) {
  return encodeURIComponent(str)
    .replace(/[!'()*]/g, c => '%' + c.charCodeAt(0).toString(16).toUpperCase());
}

POST投稿の呼び出し側はこうなります。

function postTweet(text) {
  const url = 'https://api.twitter.com/2/tweets';
  const method = 'POST';

  const oauthParams = {
    oauth_consumer_key: CONSUMER_KEY,
    oauth_nonce: Utilities.getUuid().replace(/-/g, ''),
    oauth_signature_method: 'HMAC-SHA1',  // ← 宣言
    oauth_timestamp: Math.floor(Date.now() / 1000).toString(),
    oauth_token: ACCESS_TOKEN,
    oauth_version: '1.0'
  };

  // 注意: POST /2/tweets のbodyはJSONなので、
  // 署名対象パラメータには oauth_* のみを含める(クエリ・bodyは含めない)
  oauthParams.oauth_signature = generateOAuthSignature(
    method, url, oauthParams, CONSUMER_SECRET, ACCESS_TOKEN_SECRET
  );

  const authHeader = 'OAuth ' + Object.keys(oauthParams)
    .sort()
    .map(k => `${encodeRFC3986(k)}="${encodeRFC3986(oauthParams[k])}"`)
    .join(', ');

  const response = UrlFetchApp.fetch(url, {
    method: 'post',
    contentType: 'application/json',
    headers: { Authorization: authHeader },
    payload: JSON.stringify({ text }),
    muteHttpExceptions: true
  });

  Logger.log(response.getResponseCode());
  Logger.log(response.getContentText());
}

ハマりポイントまとめ

エンジニアが踏みやすい地雷を整理しました。

  1. 署名方式の宣言と計算アルゴリズムの不一致(今回の犯人)
  2. application/json の POST では bodyを署名対象に含めない(v2の仕様)。v1.1の application/x-www-form-urlencoded 時代と異なる
  3. パーセントエンコードは encodeURIComponent だけでは不十分。!'()* を追加変換しないと一部の値で署名がズレる
  4. oauth_timestamp がサーバー時刻と大きくズレている(GASサーバーは通常正確だが、テストで固定値を使うと死ぬ)
  5. oauth_nonce の使い回し(連続実行時に被ると Could not authenticate you が出ることがある)

FAQ

Q. なぜGETは通ったのに気付けなかった?
A. GETは別関数で実装していて、そちらは computeHmacSignature(HMAC_SHA_1, ...) で正しく書けていたため。今回のミスはPOST用関数のコピペ改変時に混入。

Q. SHA256対応のOAuth 1.0a実装はないの?
A. RFC 5849では HMAC-SHA1 RSA-SHA1 PLAINTEXT のみ。X API は OAuth 1.0a で SHA1 一択です。SHA256にしたい場合は OAuth 2.0(Bearer / PKCE)に切り替える必要があります。

Q. 切り分けで最初にやるべきことは?
A. 401を受けたら以下の順で確認すると速いです。

  • oauth_signature_method の文字列
  • 署名計算で呼んでいる関数名(computeHmacSignaturecomputeHmacSha256Signature か)
  • シグネチャベースストリングを Logger.log で出力し、X公式のOAuth Signature Generatorの出力と1文字単位で比較

おわりに

「GETは通るのにPOSTだけ401」 は、認証情報ではなく 署名生成ロジックの不整合 を疑うのが最短ルートです。GASの computeHmacSignaturecomputeHmacSha256Signature の見分けをつけ、宣言と計算を一致させましょう。同じ3時間を誰かが失わずに済めば幸いです。


この記事を書いた人

BENTEN Web Works — 業務自動化・システム開発のフリーランスエンジニアです。

GAS / Python / RPA を使った業務自動化や、Web制作・システム開発のご相談を承っています。
「こんなこと自動化できる?」というご質問だけでもお気軽にどうぞ。

👉 業務自動化サービス — 詳細・お問い合わせはこちら
🐦 X(旧Twitter) — 日々の知見を発信中

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?