署名付きCookieをCloudCDNで利用する with Node.js
署名付きCookieを使うメリット
CloudCDN上で使用できる署名付きCookieを使用するメリットとしてはCloudStorage等に保存しているコンテンツをサービス提供者側の裁量で特定のユーザーに絞ってコンテンツを配信できることが挙げられます。
また、署名付きURLとの違いは署名付きURLはURLに対して署名が付与されているので認証されたユーザーに限らずURLさえあればコンテンツをダウンロードすることができますが、署名付きCookieの場合は認証されたユーザー以外にURLを公開したとしてもURL自体に署名はなく、あくまでリクエスト時に添付するCookieによって認証されます。署名付きURLよりも署名付きCookieを使うべき場面を考えた場合以下のようなケースが挙げられます。
- HLSのようなm3u8のメタデータを使用して細切れとなった動画(tsファイル)を別途取得する場合
- 大量のアセットをダウンロードする場合
- StorageのRulesで表現できない権限の制限※CloudStorageの機能を使う場合。自前でURLを発行する場合を除く。
今回使用する署名付き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は以下のような形になります。
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のアプリ内ブラウザでも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の質問があまりにも少ないので役に立てていただけると嬉しいです。
Discussion
2021/8/23
文章中の表現等を修正しました。