Twitter API や OAuth を学ぶために、ブラウザ上のピュア JavaScript で認証して GET してみました。
学習目的でムリやりブラウザ上で動作させるため、実用的なコードではないです。
1. 準備
1.1. Twitter API 申請など
申請に関しては他の記事が分かりやすいので、各自で調べて申請をお願いします。英作文は必要ですが、自分の場合は申請からすぐ API を使える状態になりました。
1.2. Access token & access token secret
ここでは簡単にするために、Twitter Developers のアプリ管理画面から自分用のアクセストークンを作成します。
2. サンプルソースコード
ここでは話を簡単にするために以下の条件でコードを書きます。
- リクエストメソッドは GET のみ
- POST は扱わない
- 認証はアクセストークンによる OAuth 認証のみ
- Bearer トークンは扱わない
- ブラウザ上で動作
- Twitter のドメイン上でブックマークレットを実行するか、デベロッパーツールで実行
- ピュア JavaScript
- 外部のライブラリは使用しない
モダンな JavaScript で書いているので、古いブラウザ等ではトランスコンパイルしないと動きません。
Chrome 79 ではコードそのままで動作確認しました。
(async () => {
const Twitter = class {
constructor(apiKey, apiSecretKey, accessToken, accessTokenSecret) {
this._apiKey = apiKey;
this._apiSecretKey = apiSecretKey;
this._accessToken = accessToken;
this._accessTokenSecret = accessTokenSecret;
}
async get(url, params) {
const query = this._percentEncodeParams(params).map(pair => pair.key + '=' + pair.value).join('&');
const method = 'GET';
// 認証情報
const authorizationHeader = await this._getAuthorizationHeader(method, url, params);
const headers = {'Authorization': authorizationHeader};
// 通信
const response = await fetch((! params ? url : url + '?' + query), {method, headers});
return response.json();
}
async _getAuthorizationHeader(method, url, params) {
// パラメータ準備
const oauthParams = [
{key: 'oauth_consumer_key' , value: this._apiKey },
{key: 'oauth_nonce' , value: this._getNonce() },
{key: 'oauth_signature_method', value: 'HMAC-SHA1' },
{key: 'oauth_timestamp' , value: this._getTimestamp()},
{key: 'oauth_token' , value: this._accessToken },
{key: 'oauth_version' , value: '1.0' }
];
const allParams = this._percentEncodeParams([...oauthParams, ...params]);
this._ksort(allParams);
// シグネチャ作成
const signature = await this._getSignature(method, url, allParams);
// 認証情報
return 'OAuth ' + this._percentEncodeParams([...oauthParams, {key: 'oauth_signature', value: signature}]).map(pair => pair.key + '="' + pair.value + '"').join(', ');
}
async _getSignature(method, url, allParams) {
const allQuery = allParams.map(pair => pair.key + '=' + pair.value).join('&');
// シグネチャベース・キー文字列
const signatureBaseString = [
method.toUpperCase(),
this._percentEncode(url),
this._percentEncode(allQuery)
].join('&');
const signatureKeyString = [
this._apiSecretKey,
this._accessTokenSecret
].map(secret => this._percentEncode(secret)).join('&');
// シグネチャベース・キー
const signatureBase = this._stringToUint8Array(signatureBaseString);
const signatureKey = this._stringToUint8Array(signatureKeyString);
// シグネチャ計算
const signatureCryptoKey = await window.crypto.subtle.importKey('raw', signatureKey, {name: 'HMAC', hash: {name: 'SHA-1'}}, true, ['sign']);
const signatureArrayBuffer = await window.crypto.subtle.sign('HMAC', signatureCryptoKey, signatureBase);
return this._arrayBufferToBase64String(signatureArrayBuffer);
}
/**
* RFC3986 仕様の encodeURIComponent
*/
_percentEncode(str) {
return encodeURIComponent(str).replace(/[!'()*]/g, char => '%' + char.charCodeAt().toString(16));
}
_percentEncodeParams(params) {
return params.map(pair => {
const key = this._percentEncode(pair.key);
const value = this._percentEncode(pair.value);
return {key, value};
});
}
_ksort(params) {
return params.sort((a, b) => {
const keyA = a.key;
const keyB = b.key;
if ( keyA < keyB ) return -1;
if ( keyA > keyB ) return 1;
return 0;
});
}
_getNonce() {
const array = new Uint8Array(32);
window.crypto.getRandomValues(array);
// メモ: Uint8Array のままだと String に変換できないので、Array に変換してから map
return [...array].map(uint => uint.toString(16).padStart(2, '0')).join('');
}
_getTimestamp() {
return Math.floor(Date.now() / 1000);
}
_stringToUint8Array(str) {
return Uint8Array.from(Array.from(str).map(char => char.charCodeAt()));
}
_arrayBufferToBase64String(arrayBuffer) {
const string = new Uint8Array(arrayBuffer).reduce((data, char) => {
data.push(String.fromCharCode(char));
return data;
}, []).join('');
return btoa(string);
}
};
const apiKey = '...';
const apiSecretKey = '...';
const accessToken = '...';
const accessTokenSecret = '...';
const url = 'https://api.twitter.com/1.1/friends/list.json';
const params = [
{key: 'screen_name', value: 'TwitterJP'}
];
const twitter = new Twitter(apiKey, apiSecretKey, accessToken, accessTokenSecret);
const json = await twitter.get(url, params);
console.log(json);
})();
3. 解説
3.1. 認証の流れ
通常の HTTP リクエストに Authorization ヘッダーを加えることで認証します。
Authorization: OAuth
oauth_consumer_key="xvz1evFS4wEEPTGEFPHBog",
oauth_nonce="6eb24361e7250e5112288fa4954dd8f634a7320c342c43019510c2cda8c8b3db",
oauth_signature_method="HMAC-SHA1",
oauth_timestamp="1581159389",
oauth_token="370773112-GmHxMAgYyLbNEtIKZeRNFsMKPR9EyMZeS9weJAEb",
oauth_version="1.0",
oauth_signature="8TACi1tsshSi9dfiLa8Vm8SasTs%3D"
※見やすくするために改行していますが、実際は 1 行で記述します。
-
oauth_consumer_key
,oauth_token
: 手持ちの API キーとアクセストークン -
oauth_signature_method
,oauth_version
: 固定値 "HMAC-SHA1", "1.0" -
oauth_timestamp
: リクエスト時の秒単位のタイムスタンプ -
oauth_nonce
: リクエストごとに固有の値。生成方法に特に規定なし -
oauth_signature
: OAuth 1.0a HMAC-SHA1 シグネチャ (※計算方法は後述)
参考「Authorizing a request — Twitter Developers」
3.2. シグネチャの計算
参考「Creating a signature — Twitter Developers」
3.2.1. 材料
- リクエストメソッド
GET
/POST
- リクエスト URL (GET の場合はクエリパラメータを付けていない状態の)
- GET や POST のパラメータ
-
oauth_*
パラメータ (oauth_signature
除く)- API キー・アクセストークン
- タイムスタンプ・NONCE
- "HMAC-SHA1", "1.0"
- シークレット
- API シークレットキー・アクセストークンシークレット
3.2.2. 計算の大まかな手順
- 「GET や POST のパラメータ」と「
oauth_*
パラメータ」を合わせて、キーの名前順にソートされたクエリ文字列にする。 - 「リクエストメソッド」「リクエスト URL」と、1 の「パラメータを合わせたクエリ文字列」をさらに合わせてクエリ文字列にする。これを「シグネチャベース」とする。
- 「API シークレットキー」「アクセストークンシークレット」を合わせてクエリ文字列にする。これを「シグネチャキー」とする。
- 「シグネチャベース」と「シグネチャキー」から HMAC-SHA1 ハッシュアルゴリズムで計算 (後述) し、バイナリ文字列のシグネチャを得る。
- バイナリ文字列のシグネチャを Base64 エンコードし、文字列のシグネチャを得る。
注意点
- URL エンコードは RFC 3986 に基づく。
- 当たり前ながら、クエリ文字列を作る際にキーと値は URL エンコードする。
- JavaScript の
encodeURIComponent()
は RFC 3986 に基づいていないため、一部文字の置換が必要。 - ちなみに PHP では
http_build_query()
を使うときにPHP_QUERY_RFC3986
を指定すると良い。
- JavaScript の
- 「パラメータを合体したクエリ文字列」を作る際に、パラメータをソートする前にキーを URL エンコードする。
- (OAuth の仕様では、キーが重複する際に値でソートするが、Twitter API の場合はキーが重複しない。)
- (リクエストメソッドは大文字にする。)
- シグネチャベースを作る際に、「パラメータを合体したクエリ文字列」の中の '&' はエスケープされ、全体として '&' が 2 つのみの状態になる。
参考「encodeURIComponent() - JavaScript | MDN」
参考「PHP: http_build_query - Manual」
3.2.3. 詳細
ほぼ同じことを書いているだけですが、より分かりやすく説明する別記事を書きました。
参考「OAuth 1.0a 認証の実装 (Twitter API 用) - Qiita」
3.3. SubtleCrypto.sign()
による HMAC-SHA1 の計算
アルゴリズムを自前で実装することもできますが、Web Crypto API に HMAC-SHA1 を計算できる機能がありますので、これを利用します。
SubtleCrypto.sign()
を使うためにはまずシグネチャベースとシグネチャキーが TypedArray などの型になっている必要があるので、文字列から変換します。また、シグネチャキーに関しては SubtleCrypto.importKey()
で CryptoKey にする必要があります。
参考「SubtleCrypto.sign() - Web APIs | MDN」
参考「SubtleCrypto.importKey() - Web APIs | MDN」
3.4. その他
本質とは関係のない部分の説明。
3.4.1. パラメータのデータの扱い方について
Object で記述したほうが簡潔に書けますし、Map で記述したほうが意味的には正しいと思うのですが、キー順にソートするところで Array.prototype.sort()
を使いたかったため、Object や Map を使っても途中で Array に変換することになるため、ここでは初めから Array で扱うことにしました。(ライブラリとして外部に公開などをする場合にはもう少し何とかすべきかもしれませんが…。)
3.4.2. RandomSource.getRandomValues()
による NONCE 乱数生成
ここでは RandomSource.getRandomValues()
で乱数のバイト列を生成しました。
Base64 やハッシュ値で NONCE を作ることもできますが、そのまま 16 進数文字列にするだけで充分と思われます。
参考「RandomSource.getRandomValues() - Web API | MDN」
3.4.3. 秒単位のタイムスタンプ取得
JavaScript の Date.now()
はミリ秒単位のタイムスタンプなので、1000 で割って切り捨てます。
3.4.4. 型変換いろいろ
// array: Uint8Array
return [...array].map(uint => uint.toString(16).padStart(2, '0')).join(''):
// str: String
return Uint8Array.from(Array.from(str).map(char => char.charCodeAt()));
// arrayBuffer: ArrayBuffer
const string = new Uint8Array(arrayBuffer).reduce((data, char) => {
data.push(String.fromCharCode(char));
return data;
}, []).join('');
return btoa(string);