アプリとdiscogsを連携する

Discogsとは

CDやレコード、アーティストなどのデータベースやマーケットプレイスなどを束ねたWebアプリケーションです。自分自身レコードを探したりその相場を調べたい時によく使っています。会員登録するとAPIが利用できます。discogsを個人で作ったアプリ連携させることでAPIのレートリミットが緩くなったり、そのアプリからDiscogsのコレクションに登録できるようになります。認証方法はOauth 1.0aです。

Discogs OAuth 1.0a フロー

  1. ConsumerKeyとシークレットを取得
  2. 1.を使ってRequest Token取得
  3. Discogsの認可ページへリダイレクト
  4. ユーザーがDiscogs上で認可
  5. CallbackでAccess Token取得
  6. 5.を使ってAPIを叩く

署名の仕組み

OAuth 1.0aの最大の特徴はリクエストごとの署名。各APIリクエストには以下のパラメータが含まれます。

  • oauth_consumer_key — アプリの識別子
  • oauth_token — アクセストークン(Access Token取得後のAPI呼び出し時に使用)
  • oauth_signature_method — 通常HMAC-SHA1
  • oauth_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_tokencreateOAuthHeaderrest引数として渡すことで署名のベースストリングに含まれます。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分で失効