署名付きCookieをCloudCDNで利用する with Node.js

5 min read読了の目安(約5200字

実際に運用するとなるとFirebaseと異なり、リアルマネーが大分必要になってくるので良くドキュメントのPricingを確認するようにしましょう。

署名付きCookieを使うメリット

特定のユーザーに絞ってHLSを配信できるようになります。FirebaseCloudStorageで良くお世話になっている署名付きURLはStorageのrulesに従ってユーザーを絞って静的コンテンツを配布できる一方デメリットがあります。
例えば、HLSのようなm3u8のメタデータを使用してtsファイルを別途取得する場合には非常に不向きです。他にもStorageのrulesで表現できる制約が非常に狭く(FirebaseCloudFirestoreのデータを使用したユーザーの絞り込み等々ができない)、サービスによる仕様の制限がたびたび発生していました。

今回使用する署名付きCookieはサービス提供者(開発者)側がCookieを発行しCloudStorage(CloudCDN)へのアクセスをコントロールし、適切なユーザーに適切なコンテンツを配布することを実現できます。

仕組み&メリット

仕組みに関しては検索すると腐る程ドキュメントが出てくるので細かいことは割愛しますが、基本は共通鍵暗号方式による署名で保護をしたコンテンツの配布を行います。

CloudCDNを例にあげると、まずそれぞれで署名を暗号•復号する時に使用する鍵を予め生成しCloudCDN側に登録しておきます。加えてサービス提供者は署名付きCookieを各自作る必要があるためCloudFunctionsなどに署名付きCookieを生成する関数(API)を用意します。この署名付きCookieにはアクセス可能なコンテンツのURLPrefix、Cookieの有効期限、CloudCDNに登録した鍵の名前を共通鍵で署名したものになり、生成したCookieをウェブブラウザやアプリケーションにHttpCookieとしてセットします。ここでのURLPrefixとはドキュメントにもありますがスキーマを含めたドメインとパスの文字列になります。例としてURLPrefixをhttps://media.example.com/image/hogeとした時にマッチする例は以下の通りです。

  • https://media.example.com/image/hoge/test.png
  • https://media.example.com/image/hoge/piyo/test.png
  • https://media.example.com/image/hogetest.png

あくまで接頭辞なのでフルマッチしたもののみを返却するわけではありません。また、クエリやフラグメントを含めることはできません。一瞬使いづらいなと思う方もいるかもしれませんが、これはHLSなどでは非常に強力な効果を発揮します。URLPrefixをhttps://media.example.com/video/hogeと指定した時、

  • https://media.example.com/video/hoge.m3u8
  • https://media.example.com/video/hoge/Sequence0.ts

二つのファイルを取得することができるようになります。以上で制限されたHLSのファイル群をCloudStorageのようなtokenをクエリに含める事なく配信を行うことができます。

デメリット

デメリットとしては今までFirebaseAuthなどでセッションはあまり考えなくて済んでいましたが、サービス提供者側が管理する必要があり今まで以上にセッションに気を遣わなければいけません。

実務利用を想定してみる

CloudCDNを利用するにはCloudLoadBalancingを使用する必要があります。CloudLoadBalancingについての説明は物凄く長くなるので省きます。
CloudLoadBalancingのURL mapsを利用するとこちらの記事でも触れられていますが、同一のドメインにCloudStorageのbucketとAppEngineのインスタンスとCloudFunctionを共存することができるようになります。よってCORSなどの設定もせずにCookieをセットすることができます。

例えば"Yorutube.jp"という動画配信サイトなどを作製すると考えた時

  • https://yorutube.jp/video以下はCloudStorageのbucket
  • https://yorutube.jp/api以下はCloudFunctions
  • その他はAppEngineにデプロイされているNext.js

のようなマップを作製することでNext.jsからCloudFunctionsを叩いた時のSet-Cookie headerは以下のような形になります。

Cloud-CDN-CookieのValueはURLEncodeしないでください。一週間以上時間を溶かします。

Set-Cookie: Cloud-CDN-Cookie=URLPrefix=ddlfsdkjfddjsslseeeMv==:Expires=1621787537579:KeyName=mySigningKey:Signature=xxxxxhldxxxxhskexxxx=; Path=/video/hoge/; Expires=Mon, May 24 2021 01:32:17 GMT+0900; Secure; HttpOnly

また本職はiOSなので、iOSアプリケーションでの利用をする場合のことも紹介させていただきます。iOSではFoundationライブラリで用意されているHttpCookieStorageクラスを使用することでHttpCookieを利用することができます。このHttpCookieStorageを使用することによってURLSessionは勿論、WebkitのSafariブラウザでもHttpCookieを利用できるそうです。

まとめ

お金に余裕があるのであればCloudCDNも利用できるので十分実務に投入できるのではないでしょうか?また、署名付きCookieだけでなく署名付きURLもサービス側で生成できるようになるとCloudStorageのrules以上の表現力を持ち、画像ファイル等の単一リソースを取得する場合に便利なのではと思います。
 ただ、注意としてCloudCDNのプライシングのみ確認するのではなく、CloudCDNを利用するHttpLoadBalancingのプライシングも別途確認するようにしましょう。CloudCDNよりもこちらのコストが非常に高いです。

余談

タイトルのwith Node.jsを回収しますが、GCPの署名付きCookieに関するドキュメントにはGoとPythonのサンプルコードしかなかったためNode.js(typescript)のコードを載せておきます。

実装

import * as crypto from "crypto"

export const generate = (
  urlPrefix: string,
  keyName: string,
  key: string,
  expiresOfUnix: number
) => {
  const decodedKeybytes = Buffer.from(key, "base64")
  const urlPrefixEncoded = Buffer.from(urlPrefix)
    .toString("base64")
    .replace(/\+/g, "-")
    .replace(/\//g, "_")

  const input = `URLPrefix=${urlPrefixEncoded}:Expires=${expiresOfUnix}:KeyName=${keyName}`
  const signature = crypto
    .createHmac("sha1", decodedKeybytes)
    .update(input)
    .digest("base64")
    .replace(/\+/g, "-")
    .replace(/\//g, "_")

  const signedValue = `${input}:Signature=${signature}`

  return signedValue
}

テストコード

import { generate } from "../cookie.ts"

describe("test generate signature", () => {
  it("success", () => {
    const url = "https://media.example.com/segments/"
    const keyName = "my-key"
    const key = "nZtRohdNF9m3cKM24IcK4w=="
    const expiration = 1558131350
    const out =
      "URLPrefix=aHR0cHM6Ly9tZWRpYS5leGFtcGxlLmNvbS9zZWdtZW50cy8=:Expires=1558131350:KeyName=my-key:Signature=_qwhz38bxCKdiDqENLIx4ujrw-U="
    const generated = generate(url, keyName, key, expiration)
    expect(generated).toEqual(out)
  })
  it("success", () => {
    const url = "http://35.186.234.33/index.html"
    const keyName = "my-key"
    const key = "nZtRohdNF9m3cKM24IcK4w=="
    const expiration = 1549751401
    const out =
      "URLPrefix=aHR0cDovLzM1LjE4Ni4yMzQuMzMvaW5kZXguaHRtbA==:Expires=1549751401:KeyName=my-key:Signature=uImwlOBCPs91mlCyG9vyyZRrNWU="
    const generated = generate(url, keyName, key, expiration)
    expect(generated).toEqual(out)
  })
})

CloudFrontのブログは山ほどあるのにCloudCDNのブログやStackOverflowの質問があまりにも少なく非常に手こずりましたがなんとか使えるようになりました、質問やコメントを頂けると嬉しいです。