本記事はPhotocreate Advent Calendar 2019 2日目の記事です。
フロントエンドなみなさんこんにちは。Andyです。
2019年3月より、業務委託という形式で株式会社Photocreateの運営するスクールフォト販売サイト「スナップスナップ」のリニューアルプロジェクトをお手伝いさせていただきました。
今回はその中でチームの仲間とチャレンジした課題についてご紹介したいと思います。
はじめに
昨今のフロントエンド界隈はエッジノードでのpre-renderingエコシステムが非常に充実してきました。ひと昔前のように「検索エンジン最適化(SEO)のためにはSSRがマスト!!」といった要件がSingle Page Application開発に求められることも減ってきた気がします。
とはいえ依然としてSSR機構を求められる案件も少なくなく、無垢な初心者が誤って迷い込もうものならその複雑怪奇な世界の闇にたちまち飲み込まれてしまうことでしょう。
本記事では認証認可とは切っても切り離せない「Cookie」という切り口でその一端をご紹介したいと思います。
前提となるアーキテクチャ
一般的なログイン
※以下の内容は実在のWebサービスの構成に若干のフェイクを加えたものです。ご了承ください。
- フロントエンドはVue.js(Nuxt.js)を利用したSPA
- Nuxt Serverにはexpressを利用
- BFF(Node.js)上でServer Side Rendering
- バックエンドはPHP(Laravel)を利用したAPIサーバー
- 認証・認可はLaravelがDBに問い合わせる形で担当
- ログイン成功時にJWT(Json Web Token)を払い出し、フロントエンドはAPIのリクエストヘッダーにTokenを含めることで認証を突破
- フロントエンドでのToken永続化にはCookieを利用
よくあるWebアプリケーションの構成ですね。 ログイン部分をシーケンス図にするとこんな感じ👇
バックエンドからのResponse Headerには Set-Cookie: <Unique Auth Token>
が含まれおり、ログインに成功した段階で自動的にブラウザにCookieが保存されます。
また、XSSによるCookieの抜き取りを防ぐために HTTPOnly オプションも設定されており、JavaScriptからCookieのread/writeを行えないようにしています。
Token Refresh
ログインで吐き出されるJWTには expire
の概念があり、その有効性には期限を設けています。
ところがこのRefreshの仕組みがちょっとやっかいで、今回のケースでもかなりのネックになりました。簡単に解説すると、
- 現在のTokenをRequest Headerに付与した状態でアクセスすると、expireの更新された新しいTokenを返却するRefresh APIが存在。
- Refresh APIにアクセスすると古いTokenは無効化される
- クライアントでページ遷移するたびにRefresh APIを実行
- 認証ルートの初回アクセス時(SSR)のタイミングでもRefresh APIを実行
という要件になっています。よくあるAccess TokenとRefresh Tokenの二元管理は行なっていません。
クライアントでページ遷移を行う際のシーケンス図はこんな感じ👇
Tokenの更新をかなりアグレッシブに行う設計になっています。特に、
認証ルートの初回アクセス時(SSR)のタイミングでもRefresh APIを実行
という要件が今回の肝になる部分です。
SSRによって起こる問題
そもそもCookieの仕組みとは?
繰り返しになりますがCookieのハンドリングには Set-Cookie
ヘッダーが利用されており、 HTTPOnly
も設定されているため基本的にJavaScriptがブラウザのCookie APIを触る事はありません。
Cookieの仕様に明るくない読者もいるかと思うので念の為解説しておくと、一般的なWebブラウザはHTTP Requestを発行する際にそのResponse Headerに Set-Cookie
が含まれていれば自動でローカルのCookie Storageに値をドメイン情報と合わせて保管してくれます。
次回以降、同一のドメインにHTTP Requestが発行される場合は、このドメインと紐づけられたCookieが自動でRequest Headerに付与されてリクエスト先に渡されるというのが基本的な仕組みです。
また、ドメインを指定する際はCookieの Domain
オプションを利用してサブドメインまでを含める事が可能です。本アプリケーションでもこの Domain
オプションによってAPIサーバーに対しても、SSRサーバーに対してもCookieが渡されるようになっています。
より詳細にCookieの仕様を学びたい方はMDNのドキュメントを読む事をお勧めします。
Embedded content: https://developer.mozilla.org/ja/docs/Web/HTTP/Headers/Set-Cookie
SSRサーバーを経由することによるCookieの消失
ここからは実際に以下のようなケースにおける問題点と解決策をそれぞれ紹介していきたいと思います。
- Client to BackendのCookie Forwarding
- Backend to ClientのCookie Forwarding
- BFF to Backendのリクエストスコープにおける共有Cookieのハンドリング
Client to Backend
本題に立ち戻りますが、本アプリケーションは(初回アクセス時のみ)クライアントとAPIサーバーの中間にSSRサーバーが挟まれるため、何もしないままだとクライアントの持つCookieがAPIサーバーまで到達しなくなってしまいます。
サーバーを跨ぐので当然ですね。
これに対処するためには、API ClientでHeaderを組み立てる際に以下のようなCookie Forwardingを仕込んであげましょう!
// APIClient.ts
import { Context } from '@nuxt/vue-app'
export class APIClient {
constructor(private ctx: Context) {
// ...初期化処理
}
request() {
// ...リクエスト処理
}
private createHeader() {
let baseHeader: Record<string, string> = {
'Content-Type': 'application/json',
...
}
// SSR時のみRequest Headerを拡張
if (process.server) {
header = {
...header,
this.ctx.req.header
}
}
return header
}
}
上のようなAPI ClientをServer middlewareで new
する際に、constructorにNuxt Contextを渡すことで ctx.req
内のリクエスト情報にアクセスできます。
このRequest ContextにはClientがSSRサーバーにアクセスした際の情報がCookieに限らずいろいろと含まれているため、無難に丸ごとforwardしてあげるのが良いでしょう。
また上記のAPI Clientは自社のAPI Server(共通ドメイン)に向けることのみを前提としているため、きちんとブラウザの挙動を再現する場合はドメインの検証ロジックをいれてあげてください。
Backend to Client
続いてSSRサーバーがBackendから受け取ったResponse Headerのforwardingについて考えてみます。
こちらもNuxt Contextが持つ res
オブジェクトを加工することでBackendからSSRサーバーに渡されたCookieをClientに伝播させる事が可能です。
1点追加要件として「Backend to Clientには set-cookie
ヘッダーのみをforwardしたい」というものが発生したため、最終的には以下のような実装になりました。API Client内で通信に用いているライブラリはaxiosです。
// APIClient.ts
...
// 型アノテーションは省略
request(hint) {
const requestObj = this.createRequestObject(hint)
return new Promise((resolve, reject) => {
axios.request(requestObj)
.then(data => {
if (!process.server) return data
...
const setCookie: string[] | undefined = data.headers['set-cookie']
if (typeof setCookie === 'object') {
// Proxy cookies
setCookie.forEach(s => {
this.ctx.res.setHeader('set-cookie', s)
})
}
})
})
}
...
ctx.res.setHeader()
を叩いてあげればSSRで描画されるHTMLのResponse HeaderにCookie情報が付与されるはず!
BFF内のリクエストスコープにおけるCookie共有
ここまでが初級編、ここからグッと複雑度があがります。
一般的にSPAで画面を描画する際は1つのHTMLために複数回のAPIコールが発生するものです。(GraphQLを利用している場合は別)
仮にA, B, C, D, Eという5種類のAPIが順番に呼ばれてAで取得したCookieをその後のB, C, D, Eで利用しなくてはならないケースを考えてみましょう。
まず始めにブラウザに置ける実行例ですが、5種類のAPIは異なるリクエストスコープと見なされるため毎回Cookie Storageへread/writeが走ります。
Aが実行された際はResponse Headerに含まれる Set-Cookie
という文字列をトリガーにCookie Storageへのwriteが行われ、同じく当該Cookie StorageをSingle Source of Truthとする後続のリクエストはすべてそちらを参照するため常に最新のCookieをRequest Headerに含める事が可能になります。
翻ってSSRサーバーですが、残念ながらNode.jsにはブラウザのように自動でCookieのパースと付与を行うような仕組みが存在しません(泣)
そこで、単一のServer Side Renderingリクエストスコープ内で使い回しが効くような仕組みを考えてみましょう。
擬似Cookie
さてブラウザにおけるCookie Storageとは、究極的には単なるKVS(Key Value Store)です。 Domain, Path, Secure, etc...諸々の属性を保持できるものの、結局のところCookieの受け取り手が知りたいのはKey名と対応する値のMapであると言う事ができます。
今回はそのようなKVSとして扱える擬似Cookie Storageを「$ssrCookie」という名前でNuxtのContextに注入してあげましょう。
まずは型定義の拡張。
// nuxt.d.ts
...
declare module '@nuxt/vue-app/types/index' {
...
$ssrCookie: Record<string, string>
}
続いて初期化のためのPluginを記述。
// ~/plugins/initializer.ts
import { Context } from '@nuxt/vue-app'
...
export default (ctx: Context) => {
ctx.$ssrCookie = ctx.$ssrCookie || {}
...
}
これで同一ユーザーのリクエストスコープでのみ有効な擬似Cookie Storageの準備が完了です。
Response Cookieのキャプチャ
Backend to SSRサーバーのResponse Headerに含まれるCookieをキャプチャして擬似Cookie Storageに格納する処理を記述します。
少々見辛いですがご勘弁🙏
// APIClient.ts
const SET_COOKIE_OPTIONS = ['Expires', 'Max-Age', 'Domain', 'Path', 'Secure', 'HttpOnly', 'SameSite']
// ^((?!Expires|Path|Max-Age|...).*)=(.*)$
const TOKEN_REGEX = new RegExp(
'^((?!' +
SET_COOKIE_OPTIONS.map((option, i) => {
return i === 0 ? option : '|' + option
}).join('') +
').*)=(.*)$',
'i'
)
...
request(hint) {
const requestObj = this.createRequestObject(hint)
return new Promise((resolve, reject) => {
axios.request(requestObj)
.then(data => {
if (!process.server) return data
...
const setCookie: string[] | undefined = data.headers['set-cookie']
const parseCookie = (hint: string) =>
hint
.split(';')
.map(s => s.trim().match(TOKEN_REGEX))
.find(Boolean)
if (typeof setCookie === 'object') {
// Store Cookies to ctx
const cookies = setCookie.map(parseCookie).filter(Boolean) as RegExpMatchArray[]
for (const cookie of cookies) {
const key = cookie[1]
const val = cookie[2]
if (!key || !val) continue
this.ctx.$ssrCookie[key] = val
}
}
})
})
}
...
かなり無理やりですが正規表現で set-cookie
に含まれる文字列からKey: Valueのみを抽出して $ssrCookie
に格納しています。
このへんのパースをよしなにやってくれる3rd party libraryも探せばあるかもしれません。
PropertyのSymbolize等は行なっていないので、時系列で見たときにより新しいResponseの値で上書きするようになっている点に注意です。
Requestの上書き
SSRサーバー to BackendのRequest Headerについては、すでにClientからのRequest Headerをforwardしているというような解説を先立って行いました。
$ssrCookie
に格納した内容での上書きも下記のように同じ部分で行います。
// APIClient.ts
...
private createHeader() {
let baseHeader: Record<string, string> = {
'Content-Type': 'application/json',
...
}
// SSR時のみRequest Headerを拡張
if (process.server) {
header = {
...header,
this.ctx.req.header
}
// Override Cookie
const cookie = Object.entries(ctx.$ssrCookie)
.map(([key, val]) => `${key}=${val};`)
.join('')
if (cookie) {
header = {
...header,
cookie
}
}
}
return header
}
Backendが解釈可能なformatで $ssrCookie
をRequst Headerにembedしています。
これでブラウザが行うようなCookieのハンドリングをNode.js上でも再現することができました! どこまで作り込むかは要件次第で調整してあげてください。
できたもの
こんなんできました
今回は突貫工事だったため力技の実装になってしまいましたが、正攻法でいくなら外からNuxt Contextを渡せるようなaxiosのreq/res interceptorを作り込むのが筋かなと思います。
ちなみに他にも当該API Clientには、
- Response HeaderからserverTimestampをパースしてVuex Storeに書き込むmiddleware
- Request Parameterを受け取ってService Workerのキャッシュを明示的にパージするmiddleware(via Cache API)
- etc...
が他にも積まれています😇
まとめ
Cookieという割と大事な部分のダーティーな処理をブラウザが引き受けてくれてることにあらためて感謝。
Client OnlyなSPA開発におけるブラウザの「当たり前」をNode.js上で再現するだけでもかなりの労力だなと思いました。合わせてセキュリティ要件も担保しなくてはいけないので、許容できる複雑度のバランスを取るのに苦労しました。
今回の発端は特殊な認証・認可の仕組みにありましたが、通常のケースでもCookieの管理はいずれぶつかる問題なのでその勉強になって良かったです。
そして多分本記事でお届けできたのはSSRのつらみの2%程度でしかないので、今後も機会があればネタを提供したいなと思います。
長文に目を通していただきありがとうございました!