アプリとdiscogsを連携する
Discogsとは
CDやレコード、アーティストなどのデータベースやマーケットプレイスなどを束ねたWebアプリケーションです。自分自身レコードを探したりその相場を調べたい時によく使っています。会員登録するとAPIが利用できます。discogsを個人で作ったアプリ連携させることでAPIのレートリミットが緩くなったり、そのアプリからDiscogsのコレクションに登録できるようになります。認証方法はOauth 1.0aです。
Discogs OAuth 1.0a フロー
- ConsumerKeyとシークレットを取得
- 1.を使ってRequest Token取得
- Discogsの認可ページへリダイレクト
- ユーザーがDiscogs上で認可
- CallbackでAccess Token取得
- 5.を使ってAPIを叩く
署名の仕組み
OAuth 1.0aの最大の特徴はリクエストごとの署名。各APIリクエストには以下のパラメータが含まれます。
oauth_consumer_key— アプリの識別子oauth_token— アクセストークン(Access Token取得後のAPI呼び出し時に使用)oauth_signature_method— 通常HMAC-SHA1oauth_timestamp— リクエスト時刻oauth_nonce— リプレイ攻撃防止のランダム値oauth_version— 1.0
これらとリクエストのHTTPメソッド・URL・パラメータを正規化し、Consumer SecretとToken Secretを結合した鍵でHMAC-SHA1署名を生成します。この署名により、通信経路でトークンが漏洩しても、秘密鍵がなければリクエストを偽造できないようになっています。
なお、Discogsの公式ドキュメントはHTTPS通信下での簡便さを理由にPLAINTEXT署名方式を推奨しています。ただしPLAINTEXTは秘密鍵をそのまま送信するためHTTPSに安全性を全面的に依存する形になります。一方HMAC-SHA1は秘密鍵を直接送らず署名のみを送るため、HTTPS以外の要因(証明書の不備やログへの記録など)でリクエストが漏洩した場合でも秘密鍵が露出しません。本実装ではより堅牢なHMAC-SHA1を採用しています。
oauth_tokenはcreateOAuthHeaderのrest引数として渡すことで署名のベースストリングに含まれます。Access Token取得後のAPI呼び出し時は必ず渡すようにしてください。
実装した処理
async function hmacSha1(baseString: string, key: string): Promise<string> {
const encoder = new TextEncoder();
const cryptKey = await crypto.subtle.importKey(
"raw",
encoder.encode(key),
{ name: "HMAC", hash: "SHA-1" },
false,
["sign"],
);
const signature = await crypto.subtle.sign(
"HMAC",
cryptKey,
encoder.encode(baseString),
);
return btoa(String.fromCharCode(...new Uint8Array(signature)));
}
function buildBaseString(
method: string,
url: string,
params: Record<string, string>,
): string {
const encodedParams = Object.entries(params)
.sort(([a], [b]) => a.localeCompare(b))
.map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`)
.join("&");
return [
method.toUpperCase(),
encodeURIComponent(url),
encodeURIComponent(encodedParams),
].join("&");
}
function buildAuthHeader(params: Record<string, string>): string {
return (
"OAuth " +
Object.entries(params)
.map(([k, v]) => `${k}="${encodeURIComponent(v)}"`)
.join(", ")
);
}
export async function createOAuthHeader(
method: string,
url: string,
consumerKey: string,
consumerSecret: string,
rest: Record<string, string> = {},
tokenSecret = "",
): Promise<string> {
const oauthParams: Record<string, string> = {
oauth_consumer_key: consumerKey,
oauth_nonce: crypto.randomUUID().replace(/-/g, ""),
oauth_signature_method: "HMAC-SHA1",
oauth_timestamp: Math.floor(Date.now() / 1000).toString(),
oauth_version: "1.0",
...rest, // oauth_token や oauth_verifier はここで展開され署名対象に含まれる
};
const baseString = buildBaseString(method, url, oauthParams);
const signingKey = `${encodeURIComponent(consumerSecret)}&${encodeURIComponent(tokenSecret)}`;
oauthParams.oauth_signature = await hmacSha1(baseString, signingKey);
return buildAuthHeader(oauthParams);
}export const getRequestToken = async (c: Context) => {
const authHeader = await createOAuthHeader(
"GET",
DISCOGS.REQUEST_TOKEN_URL,
c.env.DISCOGS_CONSUMER_KEY,
c.env.DISCOGS_CONSUMER_SECRET,
{ oauth_callback: DISCOGS.CALLBACK_URL },
);
const res = await fetch(DISCOGS.REQUEST_TOKEN_URL, {
method: "GET",
headers: {
Authorization: authHeader,
"User-Agent": "MyApp/0.1.0",
},
});
if (!res.ok) {
const text = await res.text();
throw new Error(`Failed Get Request Token: ${text}`);
}
const body = await res.text();
const params = new URLSearchParams(body);
const oauthToken = params.get("oauth_token");
const oauthTokenSecret = params.get("oauth_token_secret");
if (!oauthToken || !oauthTokenSecret) {
throw new Error("Failed Get Oauth Tokens");
}
const tokens: OAuthToken = {
oauthToken,
oauthSecretToken: oauthTokenSecret,
};
return tokens;
};
export const getAccessToken = async (
c: Context,
tokens: OAuthVerifierToken,
) => {
const authHeader = await createOAuthHeader(
"POST",
DISCOGS.ACCESS_TOKEN_URL,
c.env.DISCOGS_CONSUMER_KEY,
c.env.DISCOGS_CONSUMER_SECRET,
{
oauth_token: tokens.oauthToken,
oauth_verifier: tokens.oauthVerifierToken,
},
tokens.oauthSecretToken,
);
const res = await fetch(DISCOGS.ACCESS_TOKEN_URL, {
method: "POST",
headers: {
Authorization: authHeader,
"User-Agent": "MyApp/0.1.0",
},
});
if (!res.ok) {
const text = await res.text();
throw new Error(`Failed Get Access Token: ${text}`);
}
const body = await res.text();
const params = new URLSearchParams(body);
const oauthToken = params.get("oauth_token");
const oauthTokenSecret = params.get("oauth_token_secret");
if (!oauthToken || !oauthTokenSecret) {
throw new Error("Failed Get Oauth Tokens");
}
const accessTokens: OAuthToken = {
oauthToken,
oauthSecretToken: oauthTokenSecret,
};
return accessTokens;
};注意点
oauth_verifierはAccess Token取得時のみ使用、API呼び出し時は不要oauth_token_secretはRequest Token用とAccess Token用で別物- Authorizationヘッダーの署名対象URLと実際のfetch先URLは必ず一致させる
oauth_tokenはAPI呼び出し時も署名のベースストリングに含める(rest引数で渡す)- Access Tokenは有効期限なし(ユーザーが手動で失効させるまで)
- Request TokenとVerifierは発行から15分で失効