Deno 上の JS で OAuth 認証して Twitter API を使用する

Deno が面白そうだったので、試しに Twitter API を使ってみました。

本当は TypeScript を実行できますが、ここでは JavaScript で記述しています.


# 1. コード

以前自分が書いたコードを少し改良して、さらに Deno 向けに変更を加えました。

参考「[ブラウザ上のピュア JavaScript で OAuth 認証して Twitter API を使う](https://qiita.com/kerupani129/items/8c8819c031f08821f9d5)」

```js:main.js
import Twitter from './twitter.js';

// 
const twitter = new Twitter({
	'api_key'       : '...',
	'api_secret_key': '...',
	'access_token'       : '...',
	'access_token_secret': '...'
});

// 
const json = await twitter.get('friends/list', {
	'screen_name': 'TwitterJP'
});

console.log(json);
```

```js:twitter.js
import hmacSha1 from './hmac-sha1.js';
import * as base64 from "https://denopkg.com/chiefbiiko/base64/mod.ts";
import * as hex from "https://deno.land/std/encoding/hex.ts";

export default class Twitter {

	#options

	constructor(options) {
		this.#options = options;
	}

	get(path, paramsObj) {
		return this.#request('GET', path, paramsObj);
	}

	async #request(method, path, paramsObj) {

		const url = this.#getRestUrl(path);
		const params = this.#objToArray(paramsObj);

		// 認証情報
		const authHeader = await this.#getAuthHeader(method, url, params);

		const headers = {'Authorization': authHeader};

		// 通信
		const query = this.#percentEncodeParams(params).map(pair => pair.key + '=' + pair.value).join('&');

		const response = await fetch((! params || method === 'POST' ? url : url + '?' + query), {method, headers});

		return response.json();

	}

	async #getAuthHeader(method, url, params) {

		// パラメータ準備
		const oauthParamsObj = {
			'oauth_consumer_key'    : this.#options['api_key'],
			'oauth_nonce'           : this.#getNonce(),
			'oauth_signature_method': 'HMAC-SHA1',
			'oauth_timestamp'       : this.#getTimestamp(),
			'oauth_token'           : this.#options['access_token'],
			'oauth_version'         : '1.0'
		};

		const oauthParams = this.#objToArray(oauthParamsObj);

		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(', ');

	}

	#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.#options['api_secret_key'],
			this.#options['access_token_secret']
		].map(secret => this.#percentEncode(secret)).join('&');

		// シグネチャ計算
		const signatureUint8Array = hmacSha1(signatureBaseString, signatureKeyString);

		return base64.fromUint8Array(signatureUint8Array);

	}

	#getRestUrl(path) {
		return 'https://api.twitter.com/1.1/' + path + '.json';
	}

	/**
	 * 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);
		return hex.encodeToString(array);
	}

	#getTimestamp() {
		return Math.floor(Date.now() / 1000);
	}

	#objToArray(object) {
		return Object.entries(object).map(([key, value]) => ({key, value}));
	}

}
```

```js:hmac-sha1.js
import hmac from 'https://raw.githubusercontent.com/denolibs/hmac/master/lib/mod.ts';
import { Hash, encode } from 'https://deno.land/x/checksum@1.2.0/mod.ts';

const hashSha1 = new Hash('sha1');
const hash = bytes => hashSha1.digest(bytes).data;

const hmacSha1 = (data, key) => hmac(encode(data), encode(key), hash, 64, 20);

export default hmacSha1;
```

# 2. Deno の準備

自分は Windows Subsystem for Linux を使用しているので、Linux の方法でインストールしました。

```bash:ターミナル
curl -fsSL https://deno.land/x/install/install.sh | sh
```

環境変数 PATH に deno のインストールディレクトリを追加します。

```sh:~/.bash_profile&nbsp;等に追加
export DENO_INSTALL="/home/<ユーザー名>/.deno"
export PATH="$DENO_INSTALL/bin:$PATH"
```

参考「[Installation - The Deno Manual](https://deno.land/manual/getting_started/installation)」

# 3. Deno での実行

Deno で通信をしたい場合には、パーミッションのオプション `--allow-net` を使用します。

```bash:ターミナル
deno run --allow-net main.js
```

よりセキュアにするために、ホスト名で制限をかけることも可能です。

参考「[Permissions - The Deno Manual](https://deno.land/manual/getting_started/permissions)」

# 4. コードの説明

## 4.1. Deno での HMAC-SHA1 の計算

Deno では `Crypto` は実装されていますが `SubtleCrypto` はありませんので、他の方法で計算します。

Deno 向けの HMAC を計算するライブラリと SHA1 を計算するライブラリがそれぞれあるので、組み合わせたらできました。

参考「[GitHub - denolibs/hmac: A faster HMAC module for Deno using TypeScript and WebAssembly](https://github.com/denolibs/hmac)」
参考「[GitHub - manyuanrong/deno-checksum: Sha1、MD5 algorithms for Deno](https://github.com/manyuanrong/deno-checksum)」

SHA1 のブロック長は 512 ビット ( = 64 バイト) 、出力長は 160 ビット ( = 20 バイト) です。

参考「[SHAシリーズの比較 - SHA-1 - Wikipedia](https://ja.wikipedia.org/wiki/SHA-1#SHA%E3%82%B7%E3%83%AA%E3%83%BC%E3%82%BA%E3%81%AE%E6%AF%94%E8%BC%83)」
参考「[US Secure Hash Algorithm 1 (SHA1)](https://www.ipa.go.jp/security/rfc/RFC3174JA.html)」

## 4.2. OAuth 認証とシグネチャの計算

別記事にしました。

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

参考「[Authorizing a request — Twitter Developers](https://developer.twitter.com/ja/docs/basics/authentication/guides/authorizing-a-request)」
参考「[Creating a signature — Twitter Developers](https://developer.twitter.com/ja/docs/basics/authentication/guides/creating-a-signature)」