◆ cookie の保存の仕方がフレームワークによって違う
  ◆ 暗号化されていたり署名がついていたり
◆ koa と hapi で作った cookie をフレームワークを使わず読み取ってみる

すること

cookie はオリジンではなくドメインごとなのでポートが別でも同じ cookie を共有できます
あるサーバの 3000 と 4000 と 5000 ポートで別のサービスを起動している場合 それらの間でログイン中ユーザの情報などを共有できます
連動しているサービスだと どこかでログインしたら全部でログインできているとか 設定が共有されていると便利ですよね

実際に公開されているサービスではポート番号指定はめったに見ないので あまり需要がなさそうな内容ですが アクセスするのは同じオリジンだけどリバースプロキシで受けていてパスによって内部で動いているサービスに振り分けているということはあります
そういうのだと key-value ストアな DB にセッションデータを保持していたりしそうですがそこまでは気にしません

Cookie を見れるのでそれだけで解決しそうではあるのですが フレームワークごとに独自の保存方法をしていたりで 同じフレームワーク出ない限り簡単に見れなかったりします
今回は koa と hapi で保存したセッション情報を Node.js で読み取ってみます

ベース部分

ポートで分けても良かったのですが パスベースで

  • /koa/ : koa で処理
  • /hapi/ : hapi で処理
  • / : フレームワークなしの Node.js で処理

としました

プログラム複数起動は面倒だし と思ってこうしたのですがよく考えると Node.js プロセス 1 つで 3 つのポートをリッスンすればいいだけだったので 余計な苦労だったのですが 作ってしまった以上これでいきます

const koa = (() => {
const Koa = require("koa")
const app = new Koa()

app.use(ctx => {
ctx.body = "koa:" + ctx.path
})

return app
})()

const hapi = (() => {
const EventEmitter = require("events")
const listener = new EventEmitter()
const server = require("@hapi/hapi").Server({ autoListen: false, listener })

server.route({
method: "GET",
path: "/{any*}",
handler(req, h) {
return "hapi: " + req.path
},
})

const callback = () => (req, res) => listener.emit("request", req, res)
return { server, callback }
})()

const plain = (req, res) => {
res.end("plain")
}

require("http")
.createServer()
.on("request", (req, res) => {
if (req.url.startsWith("/koa/")) {
req.url = req.url.slice("/koa".length)
koa.callback()(req, res)
return
}
if (req.url.startsWith("/hapi/")) {
req.url = req.url.slice("/hapi".length)
hapi.callback()(req, res)
return
}
plain(req, res)
})
.listen(8000)

http サーバを作ってパスで振り分ける部分は最後のここです

	.on("request", (req, res) => {
if (req.url.startsWith("/koa/")) {
req.url = req.url.slice("/koa".length)
koa.callback()(req, res)
return
}
if (req.url.startsWith("/hapi/")) {
req.url = req.url.slice("/hapi".length)
hapi.callback()(req, res)
return
}
plain(req, res)
})

koa の場合は koa インスタンスの callback メソッドを呼び出せば request イベントのハンドラに設定する関数を受け取れます
単純にこの関数に req, res を渡すだけです

しかし hapi の場合はこういう仕組みはなく http.Server インスタンスを渡すと内部でリスナがセットされます
request 以外にもいくつかのイベントをリッスンして エラーが起きたときに適切な切断処理を行っています
connection イベントと close イベントから 現在接続中の socket のセットを保持してサーバを終了したときにそれらとの切断処理を行ったりもしています

その辺の処理は今回は不要なので EventEmitter インスタンスを Server インスタンスの代わりに渡して そこへリスナをセットしてもらいます
hapi でリクエストを処理したい場合は その EventEmitter の request イベントを emit し 引数に req, res を渡します

cookie をセットする

上のコードでは koa と hapi はリクエストが来たパスを表示するだけでした
これに cookie をセットする機能を追加します

/koa/
/hapi/

それぞれに

/save
/show

ルートを追加して /save へのアクセス時のクエリパラメータを JSON 形式で cookie へ保存します
/show は確認用で cookie へ保存した値を表示します
保存には koa は koa-session を使い hapi は標準の state を使います

const koa = (() => {
const Koa = require("koa")
const app = new Koa()
app.keys = ["ABCDabcd".repeat(4)]

app.use(require("koa-session")(app))

app.use((ctx, next) => {
if (ctx.path === "/save") {
ctx.session = ctx.query
ctx.body = "saved"
} else {
return next()
}
})

app.use((ctx, next) => {
if (ctx.path === "/show") {
ctx.body = ctx.session
} else {
return next()
}
})

app.use(ctx => {
ctx.body = "koa:" + ctx.path
})

return app
})()

const hapi = (() => {
const EventEmitter = require("events")
const listener = new EventEmitter()
const server = require("@hapi/hapi").Server({
autoListen: false,
listener,
debug: { request: ["error"] },
})

server.state("hapi-cookie", {
encoding: "iron",
password: "ABCDabcd".repeat(4),
isSecure: false,
path: "/",
})

server.route([
{
method: "GET",
path: "/save",
handler(req, h) {
return h.response("saved").state("hapi-cookie", req.query)
},
},
{
method: "GET",
path: "/show",
handler(req, h) {
return req.state["hapi-cookie"]
},
},
{
method: "GET",
path: "/{any*}",
handler(req, h) {
return "hapi: " + req.path
},
},
])

const callback = () => (req, res) => listener.emit("request", req, res)
return { server, callback }
})()

変更箇所は koa と hapi の関数内のみで後半はそのままです

cookie を読み取る

koa と hapi のそれぞれでセットした cookie をフレームワークを使わず読み取ります
plain 関数をこう置き換えました

const plain = (() => {
const Cookies = require("cookies")
const Iron = require("@hapi/iron")

return async (req, res) => {
const cookies = new Cookies(req, res, { keys: ["ABCDabcd".repeat(4)] })
const koa_cookie = cookies.get("koa.sess", { signed: true })
const hapi_cookie = cookies.get("hapi-cookie")

const koa_session = koa_cookie //
? JSON.parse(Buffer.from(koa_cookie, "base64"))
: ""

const hapi_session = hapi_cookie //
? await Iron.unseal(hapi_cookie, "ABCDabcd".repeat(4), Iron.defaults)
: ""

const str = JSON.stringify(
{
type: "plain",
koa: koa_session,
hapi: hapi_session,
},
null,
"\t"
)
res.writeHead(200, { "Content-Type": "text/html" })
res.end(`<pre>${str}</pre>`)
}
})()

下の順でリクエストを送ると

http://localhost:8000/koa/save?name=koa
http://localhost:8000/hapi/save?name=hapi
http://localhost:8000/

最後の / へのアクセスではこのように表示されます

{
"type": "plain",
"koa": {
"name": "koa",
"_expire": 1603617120525,
"_maxAge": 86400000
},
"hapi": {
"name": "hapi"
}
}

koa

koa-session では単純に base64 に変換してるだけで中身は簡単に確認できます
しかし ユーザが偽装してないことを確認するために署名がついています
そのチェックのために koa が内部で使っている cookies ライブラリを使います
オプションの keys には koa の app.keys と同じものを渡します
get のところで signed: true を設定しているので署名が正しいときのみ取得できます

hapi

hapi の場合は cookie の保存方法はいくつかから選択できますが 今回はセッションとしてよく使われる iron を指定しました
これだと cookie は暗号化されているので base64 のように簡単に中身は見れません
署名をつけることもできますが 暗号化されている以上 基本は必要ないのでつけていません

復号するには iron ライブラリが必要です
unseal 関数に cookie 本文と hapi でセットしたパスワードを入れると復号できます

全体

途中から部分的にしか書いてなかったので最後に動かせるようの全体です

const koa = (() => {
const Koa = require("koa")
const app = new Koa()
app.keys = ["ABCDabcd".repeat(4)]

app.use(require("koa-session")(app))

app.use((ctx, next) => {
if (ctx.path === "/save") {
ctx.session = ctx.query
ctx.body = "saved"
} else {
return next()
}
})

app.use((ctx, next) => {
if (ctx.path === "/show") {
ctx.body = ctx.session
} else {
return next()
}
})

app.use((ctx) => {
ctx.body = "koa:" + ctx.path
})

return app
})()

const hapi = (() => {
const EventEmitter = require("events")
const listener = new EventEmitter()
const server = require("@hapi/hapi").Server({
autoListen: false,
listener,
debug: { request: ["error"] },
})

server.state("hapi-cookie", {
encoding: "iron",
password: "ABCDabcd".repeat(4),
isSecure: false,
path: "/",
})

server.route([
{
method: "GET",
path: "/save",
handler(req, h) {
return h.response("saved").state("hapi-cookie", req.query)
},
},
{
method: "GET",
path: "/show",
handler(req, h) {
return req.state["hapi-cookie"]
},
},
{
method: "GET",
path: "/{any*}",
handler(req, h) {
return "hapi: " + req.path
},
},
])

const callback = () => (req, res) => listener.emit("request", req, res)
return { server, callback }
})()

const plain = (() => {
const Iron = require("@hapi/iron")
const Cookies = require("cookies")

return async (req, res) => {
const cookies = new Cookies(req, res, { keys: ["ABCDabcd".repeat(4)] })
const koa_cookie = cookies.get("koa.sess", { signed: true })
const hapi_cookie = cookies.get("hapi-cookie")

const koa_session = koa_cookie //
? JSON.parse(Buffer.from(koa_cookie, "base64"))
: ""

const hapi_session = hapi_cookie //
? await Iron.unseal(hapi_cookie, "ABCDabcd".repeat(4), Iron.defaults)
: ""

const str = JSON.stringify(
{
type: "plain",
koa: koa_session,
hapi: hapi_session,
},
null,
"\t"
)
res.writeHead(200, { "Content-Type": "text/html" })
res.end(`<pre>${str}</pre>`)
}
})()

require("http")
.createServer()
.on("request", (req, res) => {
if (req.url.startsWith("/koa/")) {
req.url = req.url.slice("/koa".length)
koa.callback()(req, res)
return
}
if (req.url.startsWith("/hapi/")) {
req.url = req.url.slice("/hapi".length)
hapi.callback()(req, res)
return
}
plain(req, res)
})
.listen(8000)