ブラウザ上のピュア JavaScript で OAuth 認証して Twitter API を使う

Twitter API や OAuth を学ぶために、ブラウザ上のピュア JavaScript で認証して GET してみました。

学習目的でムリやりブラウザ上で動作させるため、実用的なコードではないです。

# 1. 準備

## 1.1. Twitter API 申請など

申請に関しては他の記事が分かりやすいので、各自で調べて申請をお願いします。英作文は必要ですが、自分の場合は申請からすぐ API を使える状態になりました。

## 1.2. Access token & access token secret

ここでは簡単にするために、Twitter Developers のアプリ管理画面から自分用のアクセストークンを作成します。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/232067/4dc12fb4-3342-0f29-49fb-1e8a61cfd2e1.png)

# 2. サンプルソースコード

ここでは話を簡単にするために以下の条件でコードを書きます。

- リクエストメソッドは GET のみ
    - POST は扱わない
- 認証はアクセストークンによる OAuth 認証のみ
    - Bearer トークンは扱わない
- ブラウザ上で動作
    - **Twitter のドメイン上でブックマークレットを実行するか、デベロッパーツールで実行**
- ピュア JavaScript
    - 外部のライブラリは使用しない

モダンな JavaScript で書いているので、古いブラウザ等ではトランスコンパイルしないと動きません。

Chrome 79 ではコードそのままで動作確認しました。

```javascript:コード全体
(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 ヘッダーを加えることで認証します。

```http:Authorization&nbsp;ヘッダーの例
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](https://developer.twitter.com/en/docs/basics/authentication/oauth-1-0a/authorizing-a-request)」

## 3.2. シグネチャの計算

参考「[Creating a signature — Twitter Developers](https://developer.twitter.com/en/docs/basics/authentication/oauth-1-0a/creating-a-signature)」

### 3.2.1. 材料

- リクエストメソッド `GET`/`POST`
- リクエスト URL (GET の場合はクエリパラメータを付けていない状態の)
- GET や POST のパラメータ
- `oauth_*` パラメータ (`oauth_signature` 除く)
    - API キー・アクセストークン
    - タイムスタンプ・NONCE
    - "HMAC-SHA1", "1.0"
- シークレット
    - API シークレットキー・アクセストークンシークレット

### 3.2.2. 計算の大まかな手順

1. 「GET や POST のパラメータ」と「`oauth_*` パラメータ」を合わせて、キーの名前順にソートされたクエリ文字列にする。
1. 「リクエストメソッド」「リクエスト URL」と、1 の「パラメータを合わせたクエリ文字列」をさらに合わせてクエリ文字列にする。これを「シグネチャベース」とする。
1. 「API シークレットキー」「アクセストークンシークレット」を合わせてクエリ文字列にする。これを「シグネチャキー」とする。
1. 「シグネチャベース」と「シグネチャキー」から HMAC-SHA1 ハッシュアルゴリズムで計算 (後述) し、バイナリ文字列のシグネチャを得る。
1. バイナリ文字列のシグネチャを Base64 エンコードし、文字列のシグネチャを得る。

注意点

- URL エンコードは RFC 3986 に基づく。
- 当たり前ながら、クエリ文字列を作る際にキーと値は URL エンコードする。
    - JavaScript の `encodeURIComponent()` は RFC 3986 に基づいていないため、一部文字の置換が必要。
    - ちなみに PHP では `http_build_query()` を使うときに `PHP_QUERY_RFC3986` を指定すると良い。
- 「パラメータを合体したクエリ文字列」を作る際に、パラメータをソートする前にキーを URL エンコードする。
- (OAuth の仕様では、キーが重複する際に値でソートするが、Twitter API の場合はキーが重複しない。)
- (リクエストメソッドは大文字にする。)
- シグネチャベースを作る際に、「パラメータを合体したクエリ文字列」の中の '&' はエスケープされ、全体として '&' が 2 つのみの状態になる。

参考「[encodeURIComponent() - JavaScript | MDN](https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Global_Objects/encodeURIComponent)」
参考「[PHP: http_build_query - Manual](https://www.php.net/manual/ja/function.http-build-query.php)」

### 3.2.3. 詳細

ほぼ同じことを書いているだけですが、より分かりやすく説明する別記事を書きました。

参考「[OAuth 1.0a 認証の実装 (Twitter API 用) - Qiita](https://qiita.com/kerupani129/items/ee9d894cc67101f16c3f)」

## 3.3. `SubtleCrypto.sign()` による HMAC-SHA1 の計算

アルゴリズムを自前で実装することもできますが、Web Crypto API に HMAC-SHA1 を計算できる機能がありますので、これを利用します。

`SubtleCrypto.sign()` を使うためにはまずシグネチャベースとシグネチャキーが TypedArray などの型になっている必要があるので、文字列から変換します。また、シグネチャキーに関しては `SubtleCrypto.importKey()` で CryptoKey にする必要があります。

参考「[SubtleCrypto.sign() - Web APIs | MDN](https://developer.mozilla.org/en-US/docs/Web/API/SubtleCrypto/sign)」
参考「[SubtleCrypto.importKey() - Web APIs | MDN](https://developer.mozilla.org/en-US/docs/Web/API/SubtleCrypto/importKey)」

## 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](https://developer.mozilla.org/ja/docs/Web/API/RandomSource/getRandomValues)」

### 3.4.3. 秒単位のタイムスタンプ取得

JavaScript の `Date.now()` はミリ秒単位のタイムスタンプなので、1000 で割って切り捨てます。

### 3.4.4. 型変換いろいろ

```javascript:Uint8Array&nbsp;から&nbsp;16&nbsp;進数文字列
// array: Uint8Array
return [...array].map(uint => uint.toString(16).padStart(2, '0')).join(''):
```

```javascript:String&nbsp;から&nbsp;Uint8Array
// str: String
return Uint8Array.from(Array.from(str).map(char => char.charCodeAt()));
```

```javascript:ArrayBuffer&nbsp;から&nbsp;Base64&nbsp;文字列
// arrayBuffer: ArrayBuffer
const string = new Uint8Array(arrayBuffer).reduce((data, char) => {
	data.push(String.fromCharCode(char));
	return data;
}, []).join('');

return btoa(string);
```