◆ hapi の認証関係のまとめ
◆ ルートの options.auth に設定するもの

hapi の認証周りは少し複雑でたまに使うと忘れてるので使い方についてです
オプションの auth を使うものです

認証なし

まず認証なしのベースとするコードです
必要なパッケージをインストールします

yarn add @hapi/hapi @hapi/boom

現状の hapi バージョンは 20.0.2 です
hapi はメジャーアップデートのサイクルが早く オプションが変わったりもするので 21 以降だと書き方が変わってるかもしれません

const Hapi = require("@hapi/hapi")

!(async function () {
const server = Hapi.server({ port: 8000 })

server.route([
{
method: "GET",
path: "/",
handler() {
return "top page"
},
},
{
method: "GET",
path: "/private",
handler(req, h) {
return {}
},
},
])

await server.start()
})()

ルートは 「/」 と 「/private」 の 2 つです
現状は認証不要なので 「/private」 にもアクセスできます
この 「/private」 を認証必要にします

ヘッダーで認証

認証処理は基本的にヘッダーを対象に行われます
Basic 認証も Cookie の認証も HTTP ヘッダーのテキストを元に認証を行っています
hapi の認証も基本はヘッダーで行います

hapi で認証するには サーバに scheme と strategy を定義して ルートのオプションで strategy を指定します
scheme がクラス定義で strategy がインスタンスでルートにインスタンスを設定するようなイメージです
認証の処理は scheme で定義します
strategy では使う scheme と scheme のオプションを指定します
1 つの scheme に対してオプション違いの複数の strategy を作ることができます

ヘッダーの自己申告で認証

今回はデータベースなどは用意せず 自己申告でヘッダーの 「iam」 というキーにユーザ名を入れたらそのユーザとみなすことにします

fetch("/private", {
headers: {
iam: "user1",
},
})

というリクエストを送ると user1 として認証されたことにします
パスワードがないので偽り放題ですが 登録済みデータとの照合は今回は重要じゃないので省きます

scheme と strategy の定義はこうなります

server.auth.scheme("header-iam", (server, options) => {
return {
authenticate(req, h) {
const username = req.headers.iam
if (!username) {
return h.unauthenticated(Boom.unauthorized("err"))
}
return h.authenticated({ credentials: { username } })
},
}
})

server.auth.strategy("header-auth", "header-iam")

scheme の定義では scheme 名と関数を渡します
header-iam というのが scheme 名です
関数の方は server とオプションを受け取ってオブジェクトを返す必要があります
オブジェクトは基本的に authenticate 関数だけあれば十分です

strategy 定義では header-auth が strategy の名前です
この strategy で使う scheme が header-iam です
今回は省略していますが 3 つめの引数でオプションを渡せます

scheme 定義で渡した関数は strategy を定義すると呼び出されます
strategy 関数の 3 つめの引数が options 引数で受け取れます

authenticate 関数はリクエストごとに呼び出されます
認証に成功したら h.authenticated 失敗したら h.unauthenticated の結果を return します
h.authenticated の引数のオブジェクトには credentials プロパティが必須です
h.unauthenticated の引数はエラーオブジェクトで 基本は Boom.unauthorized で作った値を入れておけばよいです
「return h.unauthenticated()」 の代わりに throw しても大丈夫です

ルート側の指定は

	{
method: "GET",
path: "/private",
options: {
auth: "header-auth",
},
handler(req, h) {
return {
you_are: req.auth.credentials.username,
}
},
}

のようになります
options.auth に strategy 名を入れます

handler 関数では req.auth に認証関係のデータが入っています
h.authenticated で渡したオブジェクトの credentials と artifacts が含まれます

iam ヘッダーで受け取った名前を credentials.username に入れているので req.auth.credentials.username で取得できます

サーバ側の全体はこうなります

const Hapi = require("@hapi/hapi")
const Boom = require("@hapi/boom")

!(async function () {
const server = Hapi.server({ port: 8000 })

server.auth.scheme("header-iam", (server, options) => {
return {
authenticate(req, h) {
const username = req.headers.iam
if (!username) {
return h.unauthenticated(Boom.unauthorized("err"))
}
return h.authenticated({ credentials: { username } })
},
}
})

server.auth.strategy("header-auth", "header-iam")

server.route([
{
method: "GET",
path: "/",
handler() {
return "top page"
},
},
{
method: "GET",
path: "/private",
options: {
auth: "header-auth",
},
handler(req, h) {
return {
you_are: req.auth.credentials.username,
}
},
},
])

await server.start()
})()

HTTP ヘッダーを送信する必要があるので 「/」 にアクセスして そのページ内から fetch で 「/private」 へリクエストを送ります

fetch("/private", { headers: { iam: "user1" } }).then(async res => {
console.log(res.status, await res.text())
})
200 {"you_are":"user1"}

iam ヘッダーを送っていないとエラーになります

fetch("/private", { headers: {} }).then(async res => {
console.log(res.status, await res.text())
})
401 {"statusCode":401,"error":"Unauthorized","message":"err"}

エラーレスポンスになりました
message の 「err」 は Boom.unauthorized の引数なので自由に変更できます

デフォルト設定

全ページを認証必要にしたいときに すべてのルート定義で options.auth を指定しなくても全ルートのデフォルト値を設定することができます

server.auth.default("header-auth")

を実行すると すべてのルートで header-auth の認証が必要になります
ログイン画面など認証不要のページを作るには そのルート定義の options.auth に false を指定します

default は完全にすべてのルートになり 「/admin」 の中だけのデフォルトみたいなことはできません
server.route で設定する前に

for (const route of routes) {
if (route.path.startsWith("/admin/")) {
route.options.auth = "header-auth"
}
}

server.route(routes)

みたいなことをするしかありません

認証失敗時も handler で処理する

ここまでの認証処理では 失敗したときは Boom のエラーがレスポンスとして返され handler 関数の処理は実行されませんでした
それだと困る場合もあります

ルートごとに独自のエラー処理や エラー画面を作りたいときなどです
hapi では認証に失敗した場合も handler 関数を呼び出すようにすることができます
ルート定義の options.auth をこういうふうに変更します

	{
method: "GET",
path: "/private",
options: {
auth: {
strategy: "header-auth",
mode: "try",
}
},
handler(req, h) {
console.log(req.auth)
return {
authenticated: req.auth.isAuthenticated
}
},
}

「mode: "try"」 があると認証失敗でも handler 関数が呼び出されます
省略時は "required" になっているので認証に失敗するとエラーでした

"try" にしたときに認証に成功したかは req.auth.isAuthenticated で判断できます
また req.auth.error でエラーオブジェクトにアクセスできます

成功と失敗したときの結果です

fetch("/private", { headers: { iam: "user1" } }).then(async res => {
console.log(res.status, await res.text())
})
200 {"authenticated":true}
{
isAuthenticated: true,
isAuthorized: false,
isInjected: false,
credentials: { username: 'user1' },
artifacts: undefined,
strategy: 'header-auth',
mode: 'try',
error: null
}

fetch("/private", { headers: {} }).then(async res => {
console.log(res.status, await res.text())
})
200 {"authenticated":false}
{
isAuthenticated: false,
isAuthorized: false,
isInjected: false,
credentials: undefined,
artifacts: undefined,
strategy: 'header-auth',
mode: 'try',
error: Error: err
at Object.authenticate (C:\tmp\hapiauthtest\index.js:12:36)
at exports.Manager.execute (C:\tmp\hapiauthtest\node_modules\@hapi\hapi\lib\toolkit.js:51:36)
at module.exports.internals.Auth._authenticate (C:\tmp\hapiauthtest\node_modules\@hapi\hapi\lib\auth.js:258:58)
at authenticate (C:\tmp\hapiauthtest\node_modules\@hapi\hapi\lib\auth.js:234:21)
at Request._lifecycle (C:\tmp\hapiauthtest\node_modules\@hapi\hapi\lib\request.js:370:68)
at processTicksAndRejections (internal/process/task_queues.js:85:5)
at async Request._execute (C:\tmp\hapiauthtest\node_modules\@hapi\hapi\lib\request.js:279:9) {
data: null,
isBoom: true,
isServer: false,
output: { statusCode: 401, payload: [Object], headers: {} }
}
}

認証をオプショナルにする

ルート定義の options.auth.mode には "optional" も選択できます
これまでは scheme の認証処理で成功と失敗の 2 種類でしたが "optional" を使うには 3 種類にします
3 つめは「認証処理なし」です
ヘッダーがそもそも送信されて来なかったなどが当てはまります

"optional" では認証成功と認証処理なしの場合のみ handler が呼び出されます
認証失敗は "required" と同じく handler は呼び出されずエラーレスポンスが返されます

これに合わせて header-iam の仕様を少し変更します

  • iam ヘッダーが送られてこない場合は認証処理なし
  • ユーザ名が 3 文字未満は失敗
  • 3 文字以上なら成功

scheme 定義はこうなります

server.auth.scheme("header-iam", (server, options) => {
return {
authenticate(req, h) {
const username = req.headers.iam
if (!username) {
return h.unauthenticated(Boom.unauthorized(null, "header-iam"))
}
if (username.length < 3) {
return h.unauthenticated(Boom.unauthorized("err"))
}
return h.authenticated({ credentials: { username } })
},
}
})

認証処理なしの場合は Boom.unauthorized の引数を null と scheme 名にします
こうすると Boom エラーオブジェクトに isMissing というフラグが立ち hapi は認証処理が行われなかったと扱ってくれます
それぞれの Boom エラーオブジェクトの中身はこういう感じになっています

> Boom.unauthorized("err")
Error: err
at repl:1:6
at Script.runInThisContext (vm.js:123:20)
at REPLServer.defaultEval (repl.js:384:29)
at bound (domain.js:415:14)
at REPLServer.runBound [as eval] (domain.js:428:12)
at REPLServer.onLine (repl.js:700:10)
at REPLServer.emit (events.js:208:15)
at REPLServer.EventEmitter.emit (domain.js:471:20)
at REPLServer.Interface._onLine (readline.js:314:10)
at REPLServer.Interface._line (readline.js:691:8) {
data: null,
isBoom: true,
isServer: false,
output: {
statusCode: 401,
payload: { statusCode: 401, error: 'Unauthorized', message: 'err' },
headers: {}
}
}

> Boom.unauthorized(null, "foo")
Error: Unauthorized
at repl:1:6
at Script.runInThisContext (vm.js:123:20)
at REPLServer.defaultEval (repl.js:384:29)
at bound (domain.js:415:14)
at REPLServer.runBound [as eval] (domain.js:428:12)
at REPLServer.onLine (repl.js:700:10)
at REPLServer.emit (events.js:208:15)
at REPLServer.EventEmitter.emit (domain.js:471:20)
at REPLServer.Interface._onLine (readline.js:314:10)
at REPLServer.Interface._line (readline.js:691:8) {
data: null,
isBoom: true,
isServer: false,
output: {
statusCode: 401,
payload: { statusCode: 401, error: 'Unauthorized', message: 'Unauthorized' },
headers: { 'WWW-Authenticate': 'foo' }
},
message: 'Unauthorized',
isMissing: true
}

しばらく出してなかったのでサーバ側のコード全体です

const Hapi = require("@hapi/hapi")
const Boom = require("@hapi/boom")

!(async function () {
const server = Hapi.server({ port: 8000 })

server.auth.scheme("header-iam", (server, options) => {
return {
authenticate(req, h) {
const username = req.headers.iam
if (!username) {
return h.unauthenticated(Boom.unauthorized(null, "header-iam"))
}
if (username.length < 3) {
return h.unauthenticated(Boom.unauthorized("err"))
}
return h.authenticated({ credentials: { username } })
},
}
})

server.auth.strategy("header-auth", "header-iam")

server.route([
{
method: "GET",
path: "/",
handler() {
return "top page"
},
},
{
method: "GET",
path: "/private",
options: {
auth: {
strategy: "header-auth",
mode: "optional",
}
},
handler(req, h) {
console.log(req.auth)
return {
authenticated: req.auth.isAuthenticated
}
},
},
])

await server.start()
})()

それぞれの場合の実行結果を見てみます

まずは3 文字以上のユーザ名の場合です

fetch("/private", { headers: { iam: "user1" } }).then(async res => {
console.log(res.status, await res.text())
})
200 {"authenticated":true}
{
isAuthenticated: true,
isAuthorized: false,
isInjected: false,
credentials: { username: 'user1' },
artifacts: undefined,
strategy: 'header-auth',
mode: 'optional',
error: null
}

成功なのでこれまで通りです


3 文字未満のユーザ名の場合です

fetch("/private", { headers: { iam: "u" } }).then(async res => {
console.log(res.status, await res.text())
})
401 {"statusCode":401,"error":"Unauthorized","message":"err"}

失敗なので handler は呼び出されません


iam ヘッダーを送らない場合です

fetch("/private", { headers: {} }).then(async res => {
console.log(res.status, await res.text())
})
200 {"authenticated":false}
{
isAuthenticated: false,
isAuthorized: false,
isInjected: false,
credentials: null,
artifacts: null,
strategy: null,
mode: 'optional',
error: Error: Missing authentication
at module.exports.internals.Auth._authenticate (C:\tmp\hapiauthtest\node_modules\@hapi\hapi\lib\auth.js:272:26)
at processTicksAndRejections (internal/process/task_queues.js:85:5)
at async Request._lifecycle (C:\tmp\hapiauthtest\node_modules\@hapi\hapi\lib\request.js:370:32)
at async Request._execute (C:\tmp\hapiauthtest\node_modules\@hapi\hapi\lib\request.js:279:9) {
data: null,
isBoom: true,
isServer: false,
output: { statusCode: 401, payload: [Object], headers: [Object] }
}
}

失敗ではないですが 認証されていないので isAuthenticated は false です
それでも handler 関数は呼び出されています

hapi の認証については 基本的にはここまで理解してれば十分なことがほとんどだと思います

ライブラリ

ライブラリを使う場合は基本的に register すると scheme の定義を行ってくれます
使う側がやることは strategy 定義とルートへの options.auth の設定です

hapi 公式モジュールだと basic や cookie があります

認証の strategy を組み合わせる

ここまではルートに 1 つの strategy を指定しました
複数の strategy を組み合わせたいケースもあります

複数の認証方法があって どれかで認証されていれば良いというケース(OR)
2 段階や 2 要素などの すべてで認証成功しないといけないケース(AND)

hapi では標準で OR のケースがサポートされています
しかし AND のケースはサポートされていないので工夫が必要です

strategy の準備

組み合わせるための複数の strategy が必要です
ここでは単純に iam1 ヘッダーと iam2 ヘッダーで認証する scheme と strategy を作ります
処理はさっきまでのと同じで 自己申告したユーザ名を使います

server.auth.scheme("header-iam1", (server, options) => {
return {
authenticate(req, h) {
const username = req.headers.iam1
if (!username) {
return h.unauthenticated(Boom.unauthorized(null, "header-iam1"))
}
if (username.length < 3) {
return h.unauthenticated(Boom.unauthorized("err"))
}
return h.authenticated({ credentials: { username } })
},
}
})

server.auth.scheme("header-iam2", (server, options) => {
return {
authenticate(req, h) {
const username = req.headers.iam2
if (!username) {
return h.unauthenticated(Boom.unauthorized(null, "header-iam2"))
}
if (username.length < 3) {
return h.unauthenticated(Boom.unauthorized("err"))
}
return h.authenticated({ credentials: { username } })
},
}
})

server.auth.strategy("header-auth1", "header-iam1")
server.auth.strategy("header-auth2", "header-iam2")

scheme の処理はほぼ一緒なので strategy のオプションで分けて scheme 定義は 1 つでもよかったのですが 実際に strategy を組み合わせるときは別 scheme になることがほとんどだと思うので 分けてみました

OR

OR の場合は ルート定義のオプションの strategy を strategies にして配列で指定します

	{
method: "GET",
path: "/private",
options: {
auth: {
strategies: ["header-auth1", "header-auth2"],
}
},
handler(req, h) {
return {
authenticated: req.auth.isAuthenticated
}
},
}

こうすると配列の順で strategy の認証処理を行います
注意が必要なのは 次の strategy を試すのは認証失敗時ではなく 認証処理がなかったときです
mode が "optional" のときに認証なしで handler が呼び出されるのと同じときです
header-auth1 の処理で iam1 ヘッダーが送られてきてないなら header-auth2 の認証処理に移ります
header-auth1 の処理で iam1 ヘッダーが送られてきて 3 文字未満だった場合は header-auth2 は試行されずに失敗です

AND

AND の場合は hapi 側に仕組みが用意されていません
複数の strategy を試行して全部で成功したら成功とみなす strategy を自分で用意する必要があります

単純に組み合わせる scheme を作ってみました
server.auth.test に strategy と request を渡せばその strategy を試行して結果を取得できます
これを使って複数の strategy を試行して全部が成功だったら成功を返すようにしています

server.auth.scheme("multiple-strategy", (server, options) => {
return {
async authenticate(req, h) {
const strategies = options && options.strategies || []
const results = []
let error_strategy = null

for (const strategy of strategies) {
try {
const data = await server.auth.test(strategy, req)
results.push({ authenticated: true, data })
} catch (err) {
results.push({ authenticated: false, err })
error_strategy = strategy
break
}
}

const data = {
artifacts: results,
credentials: results.map(({ data }) => (data ? data.credentials : null)),
}

if (error_strategy) {
return h.unauthenticated(Boom.unauthorized("error at: " + error_strategy), data)
} else {
return h.authenticated(data)
}
},
}
})

server.auth.strategy("multiple", "multiple-strategy", {
strategies: ["header-auth1", "header-auth2"],
})

最初は optional や try の mode を選べるようにしようとしたのですが 長くなったので今回はシンプルに required の動きのみです
認証成功以外があればそこで終了して認証失敗とします

ルートのオプションでは strategy に multiple を指定するだけです

	{
method: "GET",
path: "/private",
options: {
auth: {
strategy: "multiple",
},
},
handler(req, h) {
return {
authenticated: req.auth.isAuthenticated,
}
},
}

試しに実行してみます

fetch("/private", { headers: {iam1: "abc", iam2: "def"} }).then(async res => {
console.log(res.status, await res.text())
})

fetch("/private", { headers: {iam1: "abc", iam2: "d"} }).then(async res => {
console.log(res.status, await res.text())
})

fetch("/private", { headers: {iam1: "a", iam2: "def"} }).then(async res => {
console.log(res.status, await res.text())
})

fetch("/private", { headers: {} }).then(async res => {
console.log(res.status, await res.text())
})
200 {"authenticated":true}
401 {"statusCode":401,"error":"Unauthorized","message":"error at: header-auth2"}
401 {"statusCode":401,"error":"Unauthorized","message":"error at: header-auth1"}
401 {"statusCode":401,"error":"Unauthorized","message":"error at: header-auth1"}

また今回は扱っていませんが scheme の処理で h.redirect などを使ってレスポンスを返す場合もありえます
通常は それがレスポンスとして返されますが server.auth.test を使うと少し異なります
エラーとなって try-catch の err として受け取る値が Response 型になっています
リダイレクトなどをさせるにはそれを return する必要があります

payload で認証する

ここまではヘッダーを使った認証でしたが hapi では req.payload で受け取れる POST の body 部分を使った認証も可能です
ただし あくまでヘッダーの認証がメインで追加処理という感じになります

server.auth.scheme("body-iam", (server, options) => {
return {
authenticate(req, h) {
return h.authenticated({ credentials: {} })
},
payload(req, h) {
const username = req.payload && req.payload.iam
if (!username) throw Boom.unauthorized("err")
req.auth.credentials.username = username
return h.continue
},
}
})

server.auth.strategy("body-auth", "body-iam")

scheme の authenticate 関数は必須なので ヘッダーでの認証がないならここは認証成功したことにします
そのあとでリクエストボディのパースが終わり req.payload にアクセスできるようになったら payload 関数が呼び出されます
こっちでは h.authenticated や h.unauthenticated は使えません
なので失敗なら Boom.unauthorized を throw して 成功なら credentials オブジェクトに情報を追加します

h.authenticated などが使えないことからもわかりますが payload 関数の処理は通常の認証処理ではなく別のライフサイクルメソッドとして実行されています
onPostAuth や handler などと同じような扱いです
なので throw するとそのままエラーがレスポンスになります
options.auth.mode を try にしていても handler が呼び出されません
handler を呼び出したいなら throw はせずに auth オブジェクトをエラーとわかるように変更します

また ルートのオプションで options.auth.payload を true にします

	{
method: "POST",
path: "/private",
options: {
auth: {
strategy: "body-auth",
payload: true,
},
},
handler(req, h) {
console.log(req.auth)
return {
authenticated: req.auth.isAuthenticated
}
},
}

しないと scheme の payload 関数が実行されません
true は required を指定するのと一緒で optional も選べます
optional を指定すると payload 関数が認証なしの場合 (isMissing ありの Boom オブジェクトを返した場合) にエラーになりません

scheme 側で必須なら scheme 定義で返すオブジェクトの options.payload を true にします
こっちで true にするとルート側で無効化できません

全体はこんな感じです

const Hapi = require("@hapi/hapi")
const Boom = require("@hapi/boom")

!(async function () {
const server = Hapi.server({ port: 8000 })

server.auth.scheme("body-iam", (server, options) => {
return {
authenticate(req, h) {
return h.authenticated({ credentials: {} })
},
payload(req, h) {
const username = req.payload && req.payload.iam
if (!username) throw Boom.unauthorized("err")
req.auth.credentials.username = username
return h.continue
},
}
})

server.auth.strategy("body-auth", "body-iam")

server.route([
{
method: "GET",
path: "/",
handler() {
return "top page"
},
},
{
method: "POST",
path: "/private",
options: {
auth: {
strategy: "body-auth",
payload: true,
},
},
handler(req, h) {
return {
authenticated: req.auth.isAuthenticated
}
},
},
])

await server.start()
})()

body を送ったり送らなかったりしてみます

fetch("/private", {
method: "POST",
headers: { "Content-Type": "application/json" },
}).then(async res => {
console.log(res.status, await res.text())
})

fetch("/private", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({})
}).then(async res => {
console.log(res.status, await res.text())
})

fetch("/private", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ iam: "user1" })
}).then(async res => {
console.log(res.status, await res.text())
})
401 {"statusCode":401,"error":"Unauthorized","message":"err"}
401 {"statusCode":401,"error":"Unauthorized","message":"err"}
200 {"authenticated":true}

strategies を使う場合

strategies で複数指定する場合 payload の試行は 1 つだけになります
最初にヘッダーで h.authenticated を返した strategy が使用する strategy となり req.auth.strategy にセットされます
payload 関数が実行されるのはこの strategy の payload 関数です

複数の strategy の OR で payload を見るまでどの strategy かわからないなら AND のときのように自分でそういう scheme と strategy を作るしかありません
payload で認証する場合でも どの strategy を使えばいいかを判断できる何かをリクエストのヘッダーで送っておいたほうが良いと思います

認可

hapi の req.auth は

{
isAuthenticated: true,
isAuthorized: false,
...
}

のようなものでしたが isAuthenticated (認証) と isAuthorized (認可) の 2 つがあります
Boom では 401 が unauthorized で 403 が forbidden ですがそれは別物です

これまでの auth を使った処理はすべて認証です
なので成功時には isAuthenticated が true になりました
しかし 認可は特に指定していないので成功時も isAuthorized は false でした

認可では認証されたユーザにそのルートを開く権限があるかをチェックします
hapi の認可にはルートのオプションの options.auth.access を使います
scope と entity の 2 種類があります

scope

scope は権限名みたいなものです
管理者のみが見えるルートには admin をスコープを設定しておきます
認証されたユーザが admin スコープを持っていれば開けますし もっていなければ開けません
この admin は自分でつける名前なので好きな名前にできます

ユーザが持つスコープは認証処理の credentials に含みます
credentials.scope に指定したものが認証済みユーザが持つスコープとなります

ルートと credentials のスコープは配列で複数設定することもできます
ルート側ではスコープのルールを設定できて 「!」 や 「+」 から始まると特別な意味を持ちます
「!」 から始まるものは持っていてはいけないスコープです
「!admin」 は admin スコープを持ってると開けないルートになります
「+」 から始まるものは必須なスコープです
複数のスコープを書く場合は どれかを持っていればよいというのがデフォルトです
admin が必須で foo と bar のどちらかを持っていたらという場合は ["+admin", "foo", "bar"] になります

user0 というユーザ名なら admin スコープを持ってるとして user0 や user1 というユーザ名を入れてアクセスしてみます

const Hapi = require("@hapi/hapi")
const Boom = require("@hapi/boom")

!(async function () {
const server = Hapi.server({ port: 8000 })

server.auth.scheme("header-iam", (server, options) => {
return {
authenticate(req, h) {
const username = req.headers.iam
if (!username) {
return h.unauthenticated(Boom.unauthorized(null, "header-iam"))
}
if (username.length < 3) {
return h.unauthenticated(Boom.unauthorized("err"))
}
const credentials = { username }
if (username === "user0") {
credentials.scope = "admin"
}
return h.authenticated({ credentials })
},
}
})

server.auth.strategy("header-auth", "header-iam")

server.route([
{
method: "GET",
path: "/",
handler() {
return "top page"
},
},
{
method: "GET",
path: "/private",
options: {
auth: {
strategy: "header-auth",
access: {
scope: "admin",
},
},
},
handler(req, h) {
return {
authenticated: req.auth.isAuthenticated,
authorized: req.auth.isAuthorized,
}
},
},
])

await server.start()
})()

リクエストを送った結果です

fetch("/private", { headers: {iam: "user1"} }).then(async res => {
console.log(res.status, await res.text())
})

fetch("/private", { headers: {iam: "user0"} }).then(async res => {
console.log(res.status, await res.text())
})
403 {"statusCode":403,"error":"Forbidden","message":"Insufficient scope"}
200 {"authenticated":true,"authorized":true}

entity

entity はもっと単純です
設定できるのは any, user, app のどれかです
user だと credentials.user があれば認可され app だとその逆で credentials.user がないと認可されます

ユーザとして認証されれば credentials.user に情報が入ってるはずで 入ってないのはユーザじゃなくてアプリとして認証されたものというのが前提のようです
any だとどっちかなので credentials.user があってもなくてもいいので常に認可状態です

試しに 「user」 から始まるユーザ名だと ユーザとして credentials.user にユーザ名を追加するようにしてみます
ルートは 「/private1」 と 「/private2」 を用意して 1 のほうは 「entity: "user"」 で 2 のほうは 「entity: "app"」 にします

const Hapi = require("@hapi/hapi")
const Boom = require("@hapi/boom")

!(async function () {
const server = Hapi.server({ port: 8000 })

server.auth.scheme("header-iam", (server, options) => {
return {
authenticate(req, h) {
const username = req.headers.iam
if (!username) {
return h.unauthenticated(Boom.unauthorized(null, "header-iam"))
}
if (username.length < 3) {
return h.unauthenticated(Boom.unauthorized("err"))
}
const credentials = { username }
if (username.startsWith("user")) {
credentials.user = username
}
return h.authenticated({ credentials })
},
}
})

server.auth.strategy("header-auth", "header-iam")

server.route([
{
method: "GET",
path: "/",
handler() {
return "top page"
},
},
{
method: "GET",
path: "/private1",
options: {
auth: {
strategy: "header-auth",
access: {
entity: "user",
},
},
},
handler(req, h) {
return {
authenticated: req.auth.isAuthenticated,
authorized: req.auth.isAuthorized,
}
},
},
{
method: "GET",
path: "/private2",
options: {
auth: {
strategy: "header-auth",
access: {
entity: "app",
},
},
},
handler(req, h) {
return {
authenticated: req.auth.isAuthenticated,
authorized: req.auth.isAuthorized,
}
},
},
])

await server.start()
})()

それぞれにリクエストを送ってみます

fetch("/private1", { headers: {iam: "user1"} }).then(async res => {
console.log(res.status, await res.text())
})

fetch("/private1", { headers: {iam: "foo"} }).then(async res => {
console.log(res.status, await res.text())
})

fetch("/private2", { headers: {iam: "user1"} }).then(async res => {
console.log(res.status, await res.text())
})

fetch("/private2", { headers: {iam: "foo"} }).then(async res => {
console.log(res.status, await res.text())
})
200 {"authenticated":true,"authorized":true}
403 {"statusCode":403,"error":"Forbidden","message":"Application credentials cannot be used on a user endpoint"}
403 {"statusCode":403,"error":"Forbidden","message":"User credentials cannot be used on an application endpoint"}
200 {"authenticated":true,"authorized":true}

mode は使えない

認可は認証とは別のライフサイクルメソッドです
mode は認証用のものなので try にしても認可されなかった場合に handler を実行できません

hapi の認可処理自体が自分で関数を書いて処理できず自由度がそこまでないので pre を使って自作するのもありかもしれません
その場合は throw しなければ後続の handler などのライフサイクルメソッドは実行できます

最後に

かなり長くなりましたが 基本は誰かが作ってくれたプラグインを register して 定義された scheme から strategy を作って options.auth.stategy に設定するくらいで十分だと思います