Play Framework の View に React + TypeScript を組み込み、 sbt + Vite.js ですべてコンパイルできるようにしてみた

Play Framework アプリに React を組み込んでみる機会がありました。

ただ、Reactを使って SPA にするのではなく、まずは

  • ルーティングは Play Framework (バックエンド)が担当
  • Play Framework の View上で React が動く

という、MPA (Multi Page Application) な Play Framework アプリの作成を考えました。

 
MPAで作るにはどうすれば良いかを調べたところ、以下の記事がありました。とても分かりやすく参考になりました。ありがとうございました。
Integrate Vue.js into existing Play Framework project | CyberAgent Developers Blog

 
上記の記事のまとめには

本ブログではVue.jsを既存のPlay Frameworkに統合する方法を紹介しましたが、AngularJS、Reactを統合する方法もほぼ同じです。ぜひこれを参考に、様々な Modern WebSiteを作っていきましょう。

と書かれていたことから、React であっても作れるかもしれないと考え、この記事をベースに作ってみることにしました。

ただ、いろいろ悩んだことがあったため、メモを残します。

 
目次

 

環境

実装すること

  • Play Framework の View に React を組み込むこと
    • 1つのViewで1つのReact アプリ
    • Viewでは script タグで読み込む
  • React を始めとする Assets には、フィンガープリントが付いていること
    • 73ee83e91609731acc7ecffc9854ecfb-vendor.js みたいなの
  • React は TypeScript で書くこと
  • sbt run で、Play と React の両方をコンパイルした上で動作すること

 

実装しないこと

  • SPAなアプリにすること
  • Reactから Play Framework の API にアクセスすること
  • Viteでビルドするが、バックエンドとは統合しないこと

 

構成

  • Play Frameworkまわり
    • Scala 2.13.6
    • Play Framework 2.8.8
    • sbt 1.5.5
    • sbt-digest 1.1.4
    • Java や sbt は SDKMAN! でインストール
  • Reactまわり
    • React 17.0.2
      • create-react-app ではなく、 Vite でセットアップ
    • TypeScript 4.4.3
  • ビルドツール
    • vite 2.6.2

 

作るもの

方針

ページは以下の3つを作ります。Reactの有無などは以下の通りです。

No URL 説明 Reactファイル
1 / いわゆるHome なし
2 /apple りんご一覧ページ apple.js
3 /sweet_potato さつまいも一覧ページ sweet_potato.js

 
なお、各ページのルーティングはサーバサイドで行います。

 

ディレクトリ構造

主なディレクトリとファイルは以下の通りです。

.
├── app
│   ├── controllers/    # Play の Controller
│   └── views/          # PlayのView
├── build.sbt
├── conf/
│   ├── application.conf
│   ├── logback.xml
│   ├── messages
│   └── routes
├── frontend/
│   ├── node_modules/
│   ├── package.json
│   ├── project
│   │   ├── build.properties
│   │   └── target/
│   ├── src/
│   │   ├── components/          # React の各コンポーネント
│   │   ├── pages/
│   │   │   ├── apple/           # `/apple` ページ
│   │   │   └── sweet_potato/    # `/sweet_potate` ページ
│   │   └── vite-env.d.ts
│   ├── target/
│   ├── tsconfig.json
│   ├── vite.config.ts
│   └── yarn.lock
├── logs/
├── project/
├── public/
│   └── vite_assets/             # Viteでビルドした時の出力先 兼 PlayのAssets
├── target/
└── test/

 

完成イメージ

Home

React を使わない Twirl だけのページです。各ページへのリンクを置いてあります。

 

りんご一覧ページ

Homeにある りんご一覧ページへ をクリックすると、このページへ遷移します。

 
なお、 へー ボタンを押すと、数字がカウントアップします。

カウントアップは React Hooks の useState を使い、 Twirl 上で React が動作することを確認します。

 

さつまいも一覧ページ

こちらはりんご一覧ページと同じです。

 

課題:Assets のフィンガープリントを誰が付与するか

Play Frameworkでは routes

GET  /assets/*file  controllers.Assets.versioned(path="/public", file: Asset)

と書いて、Twirlに

<link rel="stylesheet" href="@routes.Assets.versioned("assets/css/app.css")">

と書くことで、フィンガープリントが付いた Assets へのリンクが自動で作成されます。
Using reverse routing and static state | Assets Overview - 2.8.x

 
では、各ファイルにフィンガープリントを付与するのはどこで行えばよいのかを考えてみます。

Reactで付与するのも良いのですが、 React 以外の Asset を使うことも想定されます。

そこで、

なことができるかを調べてみます。
Reverse routing and fingerprinting for public assets | Assets Overview - 2.8.x

 
今回の React アプリは create-react-app を使って作成します。ただ、 create-react-app で作ったReactアプリではフィンガープリントを無効にするのは難しそうでした。
Generate build without hashed file names. · Issue #821 · facebook/create-react-app

対応するには

  • ファイルのリネーム
  • create-react-appのeject

など、少々つらい方面でした。

 
それ以外の方法を調べたところ、Vue.jsを作ったEvan You氏が開発した Vite というビルドツールがありました。
Home | Vite

Viteのドキュメントを見たところ、

と分かりました。

そこで今回は Vite を使ってみることにしました。

 

実装

Play Framework と React の初期セットアップ

まずは sbt new で Play Framework アプリを生成します。

 % sbt new playframework/play-scala-seed.g8
...
name [play-scala-seed]: mpa_app_with_play_react_vite
organization [com.example]: com.example.thinkami

 
続いて Vite を使い React をセットアップします。

# ディレクトリを移動
% cd mpa_app_with_play_react_vite


# Vite でセットアップ
% npm init vite@latest
npx: installed 6 in 2.148s
...

# React アプリを入れるディレクトリを `frontend` として設定
✔ Project name: … frontend

# React を選択
✔ Select a framework: › react

# TypeScript を使うため、 `react-ts` を選択
✔ Select a variant: › react-ts

Done. Now run:

  cd frontend
  npm install
  npm run dev


# `frontend` ディレクトリへ移動してインストール
% cd frontend
% yarn install


# Node.jsの型定義を使うため、追加でインストール
% yarn add -D @types/node

 
これで Play Framework + React の初期セットアップができました。

 

React の 各Component を作成

List コンポーネント

一覧表示するためのコンポーネントを用意します。

// src/components/List.tsx

export type Props = {
    items: string[]
}

const Component = ({items}: Props): JSX.Element => {
    return (
        <ul>
            {items.map((item) => (
                <li key={item}>
                    <p>{item}</p>
                </li>
            ))}
        </ul>
    )
}
export default Component

 

Counter コンポーネント

へー ボタンを用意し、クリックするたびに +1 するための処理を入れます。

// src/components/Counter.tsx

import {useState} from "react";

const Component = (): JSX.Element => {
    const [count, setCount] = useState(0)

    return (
        <>
            <button type="button" onClick={() => setCount((count) => count + 1)}>
                へー {count}
            </button>
        </>
    )
}
export default Component

 

ListApp コンポーネント

ListとCounterを組み合わせたコンポーネントです。

りんご一覧とさつまいも一覧のページのひな形になっています。

// src/components/ListApp.tsx

import List from "./List";
import Counter from "./Counter";

export type Props = {
    name: string
    items: string[]
}

const ListApp = ({name, items}: Props): JSX.Element => {
    return (
        <>
            <h1>{name}</h1>
            <List items={items} />
            <Counter />
            <p><a href="/">戻る</a></p>
        </>
    )
}
export default ListApp

 

Reactの各ページを作成

先ほど作成したコンポーネントReactDOM.render します。

りんご一覧ページは以下の通りです。 さつまいも一覧ページは ListApp の引数 name だけが異なるため、ここでは省略します。

// src/pages/apple/index.tsx

import React from "react";
import ReactDOM from "react-dom";
import ListApp from "../../components/ListApp";

ReactDOM.render(
    <React.StrictMode>
        <ListApp name={'りんご'} items={['シナノゴールド', '秋映', 'ふじ']} />
    </React.StrictMode>,
    document.getElementById('root')
)

 
ここまでで React の必要なページとコンポーネントができました。

 

Viteの設定ファイル frontend/vite.config.ts を編集

Viteでのビルド設定を vite.config.ts に記載します。

 

出力先ディレクトリの設定

Play Framework の Assets ディレクトリである public の中に出力すると、その先は Play Framework に任せることができそうです。

そこで、

の各設定を追加します。

なお、出力先は frontend ディレクトリの外側になるため、 resolve()__dirname.. を使って指定します。
javascript - fs: how do I locate a parent folder? - Stack Overflow

export default defineConfig({
  plugins: [react()],
  build: {
    // 再度ビルドした時に、以前のファイルを消す
    emptyOutDir: true,
    // ビルドしたファイルを変更する
    outDir: resolve(__dirname, '..', 'public', 'vite_assets'),

 

Multi-Page Appの設定を追加

今回はViewごとにReactのエントリポイントを用意する必要があります。

そこで、Multi-Page Appとなるよう、以下の記事に従い rollupOptionsInput に設定を行います。
Multi-Page App | Building for Production | Vite

今回は

  • エントリポイント src/pages/apple/index.tsx を元に apple.js を出力
  • エントリポイント src/pages/sweet_potato/index.tsx を元に sweet_potato.js を出力

となるよう、Inputのキーにはビルドしてできるファイル名を、値にはエントリポイントのファイル名を指定します。

export default defineConfig({
  build: {
    rollupOptions: {
      input: {
        apple: resolve(__dirname, 'src', 'pages', 'apple', 'index.tsx'),
        sweet_potato: resolve(__dirname, 'src', 'pages', 'sweet_potato', 'index.tsx')
      },

 

出力ファイル名の設定を追加

今回はフィンガープリントを付けない形での出力ファイル名とするため、rollupOptionsOutput に設定を行います。
build.rollupOptions | Configuring Vite | Vite

rollupOptionsについては rollup.js のドキュメントに記載があります。

今回は

を設定します。

export default defineConfig({
  build: {
    rollupOptions: {
      output: {
        entryFileNames: '[name].js',
        chunkFileNames: '[name].js',
        assetFileNames: '[name][extname]',
      }

 
以上で Vite の設定も完了です。

 

Play Framework の実装

Controller

今回は View をそのまま表示するだけです。

ここでは AppleController だけ記載しますが、他もほぼ同じ Controller です。

package controllers
import play.api.mvc._
import javax.inject._

@Singleton
class AppleController @Inject()(val cc: ControllerComponents) extends AbstractController(cc) {
  def index(): Action[AnyContent] = Action { implicit request: Request[AnyContent] =>
    Ok(views.html.apple())
  }
}

 

View

index.scala.html

Twirl そのままです。Reactを使っているページへのリンクを付けています。

@()

@main("Welcome to Play") {
  <h1>Home</h1>
  <ul>
    <li><a href="/apple">りんご一覧へ</a></li>
    <li><a href="/sweet_potato">さつまいも一覧へ</a></li>
  </ul>
}

 

apple.scala.html

ビルドしたJavaScriptscript タグで読み込みます。

@()

@main("Welcome to Play") {
  <div id="root"></div>

  <script type="module" src="@routes.Assets.versioned("vite_assets/vendor.js")"></script>
  <script type="module" src="@routes.Assets.versioned("vite_assets/apple.js")"></script>
}

 

sweet_potato.scala.html

apple.scala.html とは、2つ目の script タグの src のみ異なります。

@()

@main("Welcome to Sweet Potato") {
  <div id="root"></div>

  <script type="module" src="@routes.Assets.versioned("vite_assets/vendor.js")"></script>
  <script type="module" src="@routes.Assets.versioned("vite_assets/sweet_potato.js")"></script>
}

 

routes

各コントローラのルーティングを追加します。

なお、Assetsのルーティングはデフォルトのままです。

GET     /                           controllers.HomeController.index()
GET     /apple                      controllers.AppleController.index()
GET     /sweet_potato               controllers.SweetPotatoController.index()

# Map static resources from the /public folder to the /assets URL path
GET     /assets/*file               controllers.Assets.versioned(path="/public", file: Asset)

 

Play Frameworkのビルド設定

sbt run でフロントエンドとバックエンドが両方コンパイルできるようにする

Play Frameworkのアプリを sbt run で起動すると、 dev mode となります。

公式ドキュメントによると、dev modeで起動する場合、以下のメソッドを使うことにより処理を差し込めそうです。

  • beforeStarted(): Unit - called before the play application is started, but after all “before run” tasks have been completed.
  • afterStarted(): Unit - called after the play application has been started.
  • afterStopped(): Unit - called after the play process has been stopped.

Hooking into Play’s dev mode | Sbt Cookbook - 2.8.x

 
そこで、冒頭でも紹介した記事を参考にしながら必要なものを実装していきます。
Integrate Vue.js into existing Play Framework project | CyberAgent Developers Blog

 

PlayKeys.playRunHooks 用のクラスを作成

PlayKeys.playRunHooks 用のクラスとして、 project/PlayDevRunHook.scala を実装します。

import java.io.PrintWriter
import play.sbt.PlayRunHook
import sbt._

import scala.io.Source
import scala.sys.process.Process
import scala.util.Using

object PlayDevRunHook {
  def apply(base: File): PlayRunHook = {
    val frontendBase = base / "frontend"
    val packageJsonPath = frontendBase / "package.json"
    // フロントエンドのビルド時に vite_assets にあるファイルが消されてしまうので、package.json と同じディレクトリに置いておく
    val packageJsonHashPath = frontendBase / "package.json.hash"

    object FrontendBuildProcess extends PlayRunHook {
      var process: Option[Process] = None

      override def beforeStarted(): Unit = {
        println("Hook to Play Framework dev run -- beforeStarted")

        val currPackageJsonHash = Using(Source.fromFile(packageJsonPath)) { source =>
          source.getLines().mkString.hashCode().toString
        }.getOrElse("")

        val oldPackageJsonHash = getStoredPackageJsonHash

        // frontend/package.json が変更されたら、もう一度 'yarn install` コマンドを実行する
        if (!oldPackageJsonHash.contains(currPackageJsonHash)) {
          println(s"Found new/changed package.json. Run yarn install ...")

          // 同期的にインストールを実行
          Process("yarn install", frontendBase).!

          updateStoredPackageJsonHash(currPackageJsonHash)
        }
      }

      override def afterStarted(): Unit = {
        println(s"> Watching frontend changes in $frontendBase")
        // フロントエンドのビルド用のプロセスを立ち上げ、非同期で実行する
        process = Option(Process("yarn build --watch", frontendBase).run)
      }

      override def afterStopped(): Unit = {
        // フロントエンドのビルド用のプロセスを停止する
        process.foreach(_.destroy)
        process = None
      }

      private def getStoredPackageJsonHash: Option[String] = {
        if (packageJsonHashPath.exists()) {
          val hash = Using(Source.fromFile(packageJsonHashPath)) { source =>
            source.getLines().mkString
          }
          Some(hash.getOrElse(""))
        } else {
          None
        }
      }

      private def updateStoredPackageJsonHash(hash: String): Unit = {
        Using(new PrintWriter(packageJsonHashPath)) { writer =>
          writer.write(hash)
        }
      }
    }

    FrontendBuildProcess
  }
}

 
修正箇所としては

  • 今回のアプリでは yarn installyarn build --watch は一箇所でしか使わないため、Shell object をインライン化
  • 元の実装ではビルドしたアプリの出力先に package.json.hash を出力していたが、Viteでビルドする時に出力先のファイルをすべて削除してしまうため、 package.json と同じディレクトリに置く
  • scala.util.Using の使用や、戻り値の型を追加するなど、IDEでの警告に対応

です。

 
なお、 Shell object の記法について、一部調べたことがあったため、メモとして残します。

object Shell {
  def execute(cmd: String, cwd: File, envs: (String, String)*): Int = {
    Process(cmd, cwd, envs: _*).!
  }

  def invoke(cmd: String, cwd: File, envs: (String, String)*): Process = {
    Process(cmd, cwd, envs: _*).run
  }
}

 
まず、可変長引数まわりです。

  • envs: (String, String)*(String, String)* は可変長引数としてのタプルです。
  • Process(cmd, cwd, envs: _*)_* は、可変長引数を Process に渡しています。

このあたりは、Pythonと比較した記法が紹介されている以下がわかりやすかったです。
syntax - What does param: _* mean in Scala? - Stack Overflow

 
次は、Process のメソッド

  • !
  • run

の違いです。

これについては、Scala Standard Library に記載があり、ざっくり run は非同期・ ! は同期での実行と理解しました。

Starting Processes

To execute all external commands associated with a ProcessBuilder, one may use one of four groups of methods. Each of these methods have various overloads and variations to enable further control over the I/O. These methods are:

  • run: the most general method, it returns a scala.sys.process.Process immediately, and the external command executes concurrently.
  • !: blocks until all external commands exit, and returns the exit code of the last one in the chain of execution.
  • !!: blocks until all external commands exit, and returns a String with the output generated.
  • lazyLines: returns immediately like run, and the output being generated is provided through a LazyList[String]. Getting the next element of that LazyList may block until it becomes available. This method will throw an exception if the return code is different than zero -- if this is not desired, use the lazyLines_! method.

 

build.sbt に追記

公式ドキュメントの記述に従い、 build.sbt に追加します。

// sbt run 時に、フロントエンドもコンパイルする
PlayKeys.playRunHooks += PlayDevRunHook(baseDirectory.value)

 

ビルド時に Assets にフィンガープリントを付与する

JavaScript などの Assets の Cache Busting として、ビルド時にフィンガープリントを付与するよう設定します。

Play Framework の場合、sbt-websbt-digest を使います。

 
sbt-digest のREADMEと以下の記事を参考に、設定を行います。
Play Frameworkでブラウザキャッシュ対策(Cache Busting) - Qiita

まず project/plugins.sbt に以下を追加します。

addSbtPlugin("com.typesafe.sbt" % "sbt-digest" % "1.1.4")

 
続いて、 build.sbt に追記します。

なお、今回は開発環境でもフィンガープリントを付与する設定を行います。

lazy val root = (project in file("."))
  .enablePlugins(PlayScala)
  .enablePlugins(SbtWeb)  // 追加

// 開発環境でもフィンガープリントを付与
Assets / pipelineStages := Seq(digest)

ちなみに、sbt 1.5 より、 pipelineStages in Assets ではなく Assets / pipelineStages となったようです。
Migrating to slash syntax | sbt Reference Manual — Migrating from sbt 0.13.x

 

dist taskにて、Play Framework に React を含めるようにする

Play Framework では dist taskを使うことでバイナリバージョンができます。

その dist task にも React を含めるよう、 build.sbt に設定を追加します。

lazy val frontEndBuild = taskKey[Unit]("Execute dashboard frontend build command")

val frontendPath = "frontend"
val frontEndFile = file(frontendPath)

frontEndBuild := {
  println(Process("yarn install", frontEndFile).!!)
  println(Process("yarn build", frontEndFile).!!)
}

dist := (dist dependsOn frontEndBuild).value
stage := (stage dependsOn dist).value

 

動作確認

sbt run 時にフロントエンドもコンパイルされること

ログを見たところ、コンパイルされていました。

% sbt run
...
[info] p.c.s.AkkaHttpServer - Listening for HTTP on /0:0:0:0:0:0:0:0:9000
> Watching frontend changes in path/to/mpa_app_with_play_react_vite/frontend

(Server started, use Enter to stop and go back to the console...)

yarn run v1.22.11
warning package.json: No license field
$ tsc && vite build --watch
vite v2.6.2 building for production...

watching for file changes...

build started...
transforming...
✓ 31 modules transformed.
rendering chunks...
../public/vite_assets/apple.js          0.26 KiB / gzip: 0.21 KiB
../public/vite_assets/sweet_potato.js   0.33 KiB / gzip: 0.23 KiB
../public/vite_assets/ListApp.js        0.48 KiB / gzip: 0.30 KiB
../public/vite_assets/vendor.js         129.47 KiB / gzip: 41.77 KiB
built in 1646ms.

 

scriptタグにフィンガープリントが付いていること

Elementを開いてみると、scriptタグにフィンガープリントが付与されていることを確認できました。

 

ソースコード

Github に上げました。
https://github.com/thinkAmi-sandbox/mpa_app_with_play_react_vite

 
今回のPRはこちらです。
https://github.com/thinkAmi-sandbox/mpa_app_with_play_react_vite/pull/1

Scala + Play Framework 2.8 系で、設定ファイル(application.conf)の内容を読み込む

Scala + Play Framework 2.8系で、設定ファイル (application.conf) の内容を読み込む機会があったため、メモを残します。

 
目次

 

環境

  • Scala 2.13.6
  • Play Framework 2.8.8

 

方法

Play Framework 2.8系では

  • Controllerで play.api.Configurationplay.api. Environment をDIする
  • ConfigFactory.load().getString を使う

で行うようです。

 

動作確認

以下の application.config・Controller・Viewを用意し、試してみます。

application.config

foo=bar

 
ConfigLoaderController.scala

今回は

  • Controllerで play.api.Configuration をDIする
  • ConfigFactory.load().getString を使う

を試してみます。

package controllers

import com.typesafe.config.ConfigFactory
import play.api.mvc._

import javax.inject.Inject

class ConfigLoaderController @Inject()(val cc: ControllerComponents, val config: play.api.Configuration) extends AbstractController(cc) {
  def diConfig() = Action { implicit request: Request[AnyContent] =>
    // injectされた config を使うパターン
    Ok(views.html.di_config(config.underlying.getString("foo")))
  }

  def load() = Action { implicit request: Request[AnyContent] =>
    // Factoryでloadする
    val conf = ConfigFactory.load()
    val value = conf.getString("foo")
    Ok(views.html.load_config(value))
  }
}

 
それぞれの方法用にViewを用意して、動作を確認してみます。

DI用のView (di_config.scala.html) を

@(name: String)

@main("Welcome to Play") {
    <h1>Configuration value: @name</h1>
}

と用意したところ、値を読み込めてViewで表示できました。

 
一方、load用のView (load_config.scala.html) を

@(name: String)

@main("Welcome to Play") {
    <h1>Load value: @name</h1>
}

と用意したところ、こちらも読み込めてViewで表示できました。

 

ソースコード

Githubに上げました。
https://github.com/thinkAmi-sandbox/play_scala_hello_app

PRはこちらです。
https://github.com/thinkAmi-sandbox/play_scala_hello_app/pull/1

Scala + Play Framework なアプリを作る前段として、 sbt new した後のコードを読んで分かったこと/分からなかったことをまとめてみた

Scala + Play Framework を試してみようと、公式ドキュメントの Getting Started の Already know a bit about Play? に従ってみました。
Getting Started with Play Framework

ただ、試してみていくうちに

  • 分かったこと
  • 分からないこと

をまとめたくなったため、メモを残します。

もし「分からない」としたことについて、ご存じの方がいれば教えていただけるとありがたいです。

 
目次

 

環境

 
また、すでにPlay Frameworkのプロジェクト生成は、以下のようにして済んだものとします。

% sbt new playframework/play-scala-seed.g8
[info] welcome to sbt 1.5.5 (Eclipse Foundation Java 11.0.12)
[info] loading global plugins from path/to/.sbt/1.0/plugins
[info] set current project to new (in build file:/private/var/folders/9j/rhvj56hj4zl6kbkc0zxswj400000gn/T/sbt_d5c88b9e/new/)

This template generates a Play Scala project 

name [play-scala-seed]: hello
organization [com.example]: com.example.thinkami

Template applied in path/to/hello

 

Play Frameworkのディレクトリ構造について

.gitignore にあるディレクトリやファイルを取り除いて残ったものは、以下の通りでした。

% tree -a
.
├── .g8
│   └── form
│       ├── app
│       │   ├── controllers
│       │   │   └── $model__Camel$Controller.scala
│       │   └── views
│       │       └── $model__camel$
│       │           └── form.scala.html
│       ├── default.properties
│       └── test
│           └── controllers
│               └── $model__Camel$ControllerSpec.scala
├── .gitignore
├── app
│   ├── controllers
│   │   └── HomeController.scala
│   └── views
│       ├── index.scala.html
│       └── main.scala.html
├── blog.md
├── build.sbt
├── conf
│   ├── application.conf
│   ├── logback.xml
│   ├── messages
│   └── routes
├── logs
│   └── application.log
├── project
│   ├── build.properties
│   ├── plugins.sbt
│   └── project
├── public
│   ├── images
│   │   └── favicon.png
│   ├── javascripts
│   │   └── main.js
│   └── stylesheets
│       └── main.css
└── test
    └── controllers
        └── HomeControllerSpec.scala

 
それぞれのディレクトリの役割については、公式ドキュメントに記載がありました。

公式の2.8系のドキュメントと日本語である2.3系のドキュメントでは、2.8系では dist ディレクトリが増えているくらいの違いでした。

 
ただ、上記ドキュメントについては .g8 ディレクトリの説明がなかったため、調べてみました。

 

.g8 ディレクトリについて

stackoverflowによると、 scaffolds 用のものが置いてあるディレクトリのようです。

The project supports using giter8 to create scaffolds So technically it is safe to delete, but you will lose the g8Scaffold form feature.

scala - Is .g8 directory necessary? - Stack Overflow

 
play-scala-seed.g8リポジトリはこちら。
playframework/play-scala-seed.g8: Play Scala Seed Template: run "sbt new playframework/play-scala-seed.g8"

 
ディレクトリ構造がわかったので、次は実行可能なコードが含まれる app ディレクトリの中を見てみます。

 

Controllerについて

新規プロジェクトの生成によりできた HelloController を見てみます。

import とコメントを消してみると、こんな感じでした。

@Singleton
class HomeController @Inject()(val controllerComponents: ControllerComponents) extends BaseController {
  def index() = Action { implicit request: Request[AnyContent] =>
    Ok(views.html.index())
  }
}

 
これを読んで、分かったこと/分からなかったことがあったため、まとめてみます。

 

分かったこと

@Injectについて

@Inject()JSR330アノテーションによるDIと理解しました。詳しくは以下が参考になりました。

 
この @Inject を使い、 trait ControllerComponents のデータがinjectされます。

trait ControllerComponents

The base controller components dependencies that most controllers rely on.

とあり、コントローラーで必要な機能を持っているようです。 https://www.playframework.com/documentation/2.8.x/api/scala/play/api/mvc/ControllerComponents.html

 
そして、 inject した内容は、以下のような順で渡されていくようです。
ControllerComponents の中身を少し見てみる – tchiba.dev

 

プライマリーコンストラクタについて

(val controllerComponents: ControllerComponents) の部分は、プライマリーコンストラクタでした。

プライマリコンストラクタの引数にval/varをつけるとそのフィールドは公開され、外部からアクセスできるようになります。

クラス · Scala研修テキスト

 
また、プライマリーコンストラクタで使用している val は値 (Value) で再代入不可、 var は変数 (Variable) で再代入可でした。
基本 | Scala Documentation

 

分からないこと

@Singleton を付与する基準

上記のように、生成された HelloController には @Singleton が付いています。

ただ、Web上のサンプルコードを見ると、Controllerに @Singleton が付いていない実装もありました。

 
@Singleton について調べたところ、stackoverflowには

In general, it is probably best to not use @Singleton unless you have a fair understanding of immutability and thread-safety. If you think you have a use case for Singleton though just make sure you are protecting any shared state.

In a nutshell, don't use @Singleton.

playframework - Why use singleton controllers in play 2.5? - Stack Overflow

や、上記を引用した別の質問へのコメントがありました。

In effect HomeController is behaving like a singleton and avoiding singletons could be safer. For example, by default Play Framework uses Guice dependency injection which creates a new controller instance per request as long as it is not a @Singleton. One motivation is there is less state to worry about regarding concurrency protection as suggested by Nio's answer:

multithreading - Play Scala and thread safety - Stack Overflow

 
とはいえ、これだけでは @Singleton を使うかどうかの判断基準が分かりませんでした。

 

Controllerで BaseController と AbstractController のどちらを継承させるかの基準

HomeController では BaseControllerextends していました。

class HomeController @Inject()(val controllerComponents: ControllerComponents) extends BaseController {}

 
公式ドキュメントでControllerで継承すべきものを見てみると、以下に記載がありました。
Scala Controller changes | Migration26 - 2.6.x

  • BaseController
  • AbstractController
  • InjectedController

 
このうち InjectedController については、説明に

a trait, extending BaseController, that obtains the ControllerComponents through method injection (calling a setControllerComponents method). If you are using a runtime DI framework like Guice, this is done automatically.

とあり、Play FrameworkのデフォルのDI フレームワークである Guice でのDIではなく、メソッドでのDIをする時に使うものと理解しました。

また、 AbstractController については、説明に

an abstract class extending BaseController with a ControllerComponents constructor parameter that can be injected using constructor injection.

とありました。

そのため、基本は AbstractController を使うのかなと思いつつも、「BaseController を使うケースはどんなものがあるのか」が分からなかったことから、 AbstractControllerBaseController を使い分ける基準が分かりませんでした。

 

Indexメソッドのブロック式にある構文

Indexメソッドは以下の実装でした。

def index() = Action { implicit request: Request[AnyContent] =>
  Ok(views.html.index())
}

 
まず、 = の左辺の def index() は Index メソッドを定義していると理解しました。

 
次に、 = の右辺の Action {} は、 Action の引数としてブロック式を渡していると理解しました。

Scalaでは {} で複数の式の並びを囲むと、それ全体が式になりますが、便宜上それをブロック式と呼ぶことにします。

ブロック式 | Scalaの制御構文 · Scala研修テキスト

Actionの引数にブロック式を渡せることについては

Scalaでは、引数を1個だけ渡すメソッド呼び出しなら、引数を囲む括弧を中括弧に変えても良いことになっている。 ... 引数を1個渡すときに括弧ではなく中括弧を使える機能は、クライアントプログラマーが中括弧の間に関数リテラルを書き込めるようにすることを目的としている。そうすれば、メソッド呼び出しなのに、制御構造を使っているような感じが強まる

Scalaスケーラブルプログラミング 第4版 - インプレスブックス p180

と理解しました。

以下の記事を参考にしながらActionの実装を見ても、Actionがブロック式を受け取るように見えたためです。

 
問題はブロック式の中身です。ワンライナーに直すとこんな感じです。

implicit request: Request[AnyContent] => Ok(views.html.index())

「暗黙的なパラメータである request を受け取り、 Ok を返す」という処理は分かりましたが、記法に対する自分の理解が合っているのかが分かりませんでした。

 
まず、 => を使っていることから、この部分は関数リテラルと考えました。

次に => の左辺 implicit request: Request[AnyContent] については、暗黙のパラメータと考えました。

ここの暗黙のパラメータの意味については、実践Scala入門:書籍案内|技術評論社 の p97 に

「sumを呼び出す時に、implicit とマークされた Adder[T] の値が存在すれば、それを暗黙のうちに補完してください」とコンパイラに指示するものです

とあることから、この部分は「関数リテラルを呼び出す時に、 implicit とマークされた request の値があれば、暗黙のうちに補完する」と理解しました。

 
ただ、ここで悩んだのは関数リテラルの書式です。

言語仕様のページを見ると

Expr            ::=  (Bindings | [‘implicit’] id | ‘_’) ‘=>’ Expr
ResultExpr      ::=  (Bindings | ([‘implicit’] id | ‘_’) ‘:’ CompoundType) ‘=>’ Block
Bindings        ::=  ‘(’ Binding {‘,’ Binding} ‘)’
Binding         ::=  (id | ‘_’) [‘:’ Type]

とありました。
https://scala-lang.org/files/archive/spec/2.13/06-expressions.html#anonymous-functions

Action の引数は ResultExpr を指しているのかなと考えたものの、果たしてこれが正しい理解なのかは分かりませんでした。

そこでためしに、

def index() = Action { (implicit request: Request[AnyContent]) => Ok(views.html.index()) }

と実装を変えてみたところ、 '=>' expected but ')' found. というエラーでコンパイルができなくなりました。

一方、implicit を外してみた

def index() = Action { (request: Request[AnyContent]) => Ok(views.html.index()) }

コンパイルできました。

これらを見る限り理解は合ってそうでしたが、自身は持てませんでした。

その他参考にしたところは以下の通りです。

 

Viewについて

app/views には

の2つのViewテンプレートがありました。

公式ドキュメントによると

Play comes with Twirl, a powerful Scala-based template engine, whose design was inspired by ASP.NET Razor.

The template engine | Scala Templates - 2.8.x

とのことです。

 

conf/routes について

routes はルーティングファイルでした。
Scala Routing - 2.8.x

複数のルーティングファイルを用意することもできそうです。
scala - PlayFramework: multiple routes file in project - Stack Overflow

 

project/build.properties で sbt のバージョンを指定

生成したプロジェクトを起動してみると

[info] welcome to sbt 1.5.2 (Eclipse Foundation Java 11.0.12)

と、ローカルでは sbt 1.5.5 で動作するはずが、1.5.2 で動作していました。

原因は build.properties

sbt.version=1.5.2

と書かれていたためです。
Ensure sbt and Scala versions compatibility | sbt | IntelliJ IDEA

 
そこで、build.properties

sbt.version=1.5.5

としたところ、

[info] welcome to sbt 1.5.5 (Eclipse Foundation Java 11.0.12)

と表示されるようになりました。

 

.sdkmanrc について

ここは Play Framework とは関係ありませんが。。。

pyenvやrbenvのようにディレクトリに移動したら自動的に SDKMAN! でインストールした Java や sbt のバージョンを切り替えたくなりました。

以下の記事によると、 SDKMAN! でインストールした場合も、 .sdkmanrc ファイルがあれば切り替えができそうです。 sdkman で複数のバージョンの Java をディレクトリーごとに切り替える - mike-neckのブログ

そこで、生成したプロジェクトには .sdkmanrc ファイルは含まれていないことから、追加してみました。

java=11.0.12-tem
sbt=1.5.5

 

その他分からないこと

関数リテラルと無名関数とラムダ式の違い

=> まわりを調べていたところ

などの用語を見かけました。

Scalaという文脈では、上記の言葉の定義に違いがあるのかどうかが分かりませんでした。

Next.js に CASL React を組み込んで権限管理をしてみた

アプリケーションの権限設計について調べていたところ、以下の記事に出会いました。
アプリケーションにおける権限設計の課題 - kenfdev’s blog

権限設計で考えなければいけないことがまとまっていて、とても参考になりました。ありがとうございました。

 
上記の記事を読む中で

  • RBAC(Role-Based Access Control)
  • ABAC(Attribute-Based Access Control)

あたりを試してみたくなりました。

ただ、スクラッチで作るには時間がかかりそうだったため、記事で紹介されているライブラリを使ってみることにしました。

 
調べてみたところ、JavaScript製の CASL

JAXenter: When should you use CASL?

Sergii Stotskyi: Whenever you have a requirement to implement Access Control in the application. CASL, in its core, implements ABAC (i.e., Attribute Based Access Control), but it can be successfully used to implement RBAC (Role Based Access Control) and even Claim based access control.

"CASL is an isomorphic JavaScript permission management library"

という点で良さそうでした。

 
また、実装した例なども色々と見つかりました。

 
そこで、Next.js に CASL を組み込んで権限管理をしてみることにしました。

ただ、色々と悩んだことがあったため、メモを残します。

 
目次

 

環境

実装すること

  • Next.js に CASL React で組み込む
  • 認証(ログイン)は NextAuth.js を使う
  • 用意するページは2つ
    • /
      • ログインページ
      • ToDoが書かれているページへのリンクあり
    • /todo/<id>
      • ToDoが書かれているページ
      • ログインユーザーのロールに従い、画面の表示を変える

 

実装しないこと

  • TypeScriptで書く
  • NextAuth.js での認証はがんばらない
    • 今回の検証ではログインさえできれば良いので、OAuthとか使わず、ユーザーとパスワードにする
    • ユーザー名とパスワードが一致していれば、ログインOKにする
  • ロールはがんばらない
    • ログインしたユーザーの name をロールにする
  • エラーハンドリング
    • 記事の最後にありますが、今回は CASL を使うのが目的なため、エラーハンドリングはあきらめています。。。

 

構成

  • Next.js 11.1.2
  • React 17.0.2
  • @casl/react 2.3.0
  • @casl/ability 5.4.3
  • next-auth 3.29.0

 

NextAuth.js によるログイン機能を実装

今回はログインをがんばらないため、NextAuth.js を使ってログイン機能を実装することにしました。

また、手軽にログインを実現したかったため、NextAuth.js では OAuth2.0 などを使わず、ユーザーとパスワードだけでログインすることとしました。

そこで、以下の記事を参考に、ユーザーとパスワードだけでログインできるようにしてみます。
Next.js + NextAuthでメールアドレス/パスワード認証をする

なお、今回はCASLを使うのが目的であったため、「ユーザー名とパスワードが一致すれば、ログインOK」というセキュリティ的には明らかによろしくない実装となっています。

 
NextAuth.js の実装は上記の記事通りですので、細かな説明は省略します。

まずはインストールします。

# 現在のディレクトリ名で Next.js のアプリを作り、現在のディレクトリの中に各種ソースを入れる
% npx create-next-app .

# NextAuth を追加
$ yarn add next-auth

 
続いて、 NextAuth.js 用APIを作成します。

# NextAuth 用APIのファイルを用意
% mkdir pages/api/auth 
% touch "pages/api/auth/[...nextauth].js"

 
pages/api/auth/[...nextauth].js を実装します。

import NextAuth from 'next-auth'
import Providers from 'next-auth/providers'

const authenticate = credentials =>
  // CASLの検証が目的なので、認証は適当...
  credentials.name === credentials.password ?
    { id: 1, name: credentials.name } :
    null;

const options = {
  providers: [
    Providers.Credentials({
      name: 'Name',
      credentials: {
        name: { label: 'Name', type: 'text', placeholder: 'admin' },
        password: { label: 'Password', type: 'password' },
      },
      authorize: async credentials => {
        const user = authenticate(credentials)
        return user ?
          Promise.resolve(user) :
          Promise.resolve(null)
      },
    }),
  ],
}

export default (req, res) => NextAuth(req, res, options)

 
pages/_app.js を変更します。

import {Provider} from 'next-auth/client'

export default function MyApp({ Component, pageProps }) {
  return (
    <Provider session={pageProps.session}>
      <Component {...pageProps} />
    </Provider>
  )
}

 
pages/index.js にログイン機能とToDoページへのリンクを追加します。

import {signIn, signOut, useSession} from 'next-auth/client'
import Link from 'next/link'

export default function Home() {
  const [session, loading] = useSession();

  if (loading) {
    return <div>Loading...</div>
  }

  return (
    <div>
      {session && (
        <>
          Signed in as {session.user.name} <br/>
          <button onClick={signOut}>Sign out</button>
        </>
      )}
      {!session && (
        <>
          Not signed in <br/>
          <button onClick={signIn}>Sign in</button>
        </>
      )}

      <Link href="/todo/1">
        <a>Go Todo</a>
      </Link>
    </div>
  )
}

 
package.json で、Next.js の起動ポートを変更しておきます。

{
  "name": "nextjs_casl-sample",
  "scripts": {
    "dev": "next dev -p 4800",
//...

 
なお、NEXTAUTH_URL のポートはNext.jsと合わせます。

NEXTAUTH_URL=http://localhost:4800

 
実装が終わったため、 yarn run dev して、ログインできるようになればOKです。

 

CASLによる権限管理を実装

以下の公式ドキュメントやサンプルを参考にしながら、CASL を Next.js に組み込んでいきます。

 

権限管理 (Ability) の作成

今回はログインしたユーザーごとに権限が異なるため、サンプルのように user を引数に取る

  • buildAbilityFor
  • defineRulesFor

を作成していきます。

今回、公式サンプルと異なり、 subject type detection を行いません。

また、 ユーザー名 = ロール名 として、以下の権限で設定します。

ユーザー名 Staff部分の表示 Manager部分の表示 Admin部分の表示
admin o o o
manager o o x
staff o x x
その他 x x x

 
まずは、公式サンプル通り、 AbilityBuilder を使って権限 (ability ) を config/abilities.js ファイルに定義します。
AbilityBuilder class| Define Rules | CASL

import {Ability, AbilityBuilder} from '@casl/ability';

export default function defineRulesFor(user) {
  const {can, rules} = new AbilityBuilder(Ability);

  switch(user?.name) {
    case 'admin':
      can('manage', 'all');
      break;
    case 'manager':
      can('show', 'Manager');
      can('show', 'Staff');
      break;
    case 'staff':
      can('show', 'Staff');
      break;
    default:
  }

  return rules;
}

export function buildAbilityFor(user) {
  // 公式サンプルと異なり、今回は subject type detection を行わないので、オプションなしでインスタンス生成
  return new Ability(defineRulesFor(user));
}

 

AbilityProvider コンポーネントの作成

公式サンプルでは、 App.tsxbuildAbilityFor を使って ability をインスタンス化しています。

// https://github.com/stalniy/casl-examples/blob/19c2dbd1c3dc9fb855ed24108b4481a4eafca5ba/packages/react-todo/src/App.tsx#L7

const ability = buildAbilityFor('member');

 
しかし、今回は / でログインしたユーザーを元に ability をインスタンス化する必要があるため、 _app.js では ability を決め打ちできません。

そこで、

  • ability を useState フックで保存
  • AuthNext.js の useSession フックを使って取得できる session の状態を監視する useEffect フックを用意
  • ability をグローバルに扱えるよう、 useContext フックを使用

というような処理を行うコンポーネント (components/AbilityProvider.jsx) を用意します。

import {createContext, useEffect, useState} from 'react';
import {useSession} from 'next-auth/client';
import {buildAbilityFor} from '../config/abilities';

// グローバルな state を管理する Context を用意
export const AbilityContext = createContext({});

const Component = props => {
  const [session] = useSession();
  const [ability, setAbility] = useState({});

  // session の値が変わったら、session の内容により、abilityの値を変える
  useEffect(() => {
    session ? setAbility(buildAbilityFor(session.user)) : setAbility({});
  }, [session]);

  const {children} = props;

  // Providerコンポーネントを用意し、 ability をグローバルで扱えるようにする
  return (
    <AbilityContext.Provider value={{ability}}>
      {children}
    </AbilityContext.Provider>
  )
}
export default Component

 

_app.js に AbilityProvider を追加

アプリのグローバルで扱えるよう、_app.jsAbilityProvider を追加します。

sessionのProviderの下で、 Component を囲むように定義します。

import {Provider} from 'next-auth/client'
import AbilityProvider from '../components/AbilityProvider';

export default function MyApp({ Component, pageProps }) {
  return (
    <Provider session={pageProps.session}>
      <AbilityProvider>
        <Component {...pageProps} />
      </AbilityProvider>
    </Provider>
  )
}

 

権限ごとに表示が異なるページを用意

pages/todo/[todoId].js に実装します。

このページでは

  • 権限ごとに表示を変えるため、Context から ability を取り出す
    • const {ability} = useContext(AbilityContext);
  • ability.can() により、表示可否を決める
    • 例: {ability.can('show', 'Admin') && <li>Admin</li>}
  • ログインユーザー名を表示するため、 session を取り出す
    • const [session] = useSession();

を行います。

import {useRouter} from 'next/router'
import {useContext} from 'react'
import {AbilityContext} from '../../components/AbilityProvider';
import {useSession} from 'next-auth/client';

const Page = () => {
  const router = useRouter();
  const {todoId} = router.query;
  const {ability} = useContext(AbilityContext);
  const [session] = useSession();

  return (
    <div>
      <h1>ToDo</h1>
      <p>User: {session.user.name}</p>
      <p>Todo ID: {todoId}</p>
      <ul>
        {ability.can('show', 'Admin') && <li>Admin</li>}
        {ability.can('show', 'Manager') && <li>Manager and above</li>}
        {ability.can('show', 'Staff') && <li>Staff and above</li>}
        <li>Everyone</li>
      </ul>
    </div>
  );
}

export default Page

 

ToDoページに直接アクセスした時には404を表示する

ここまでで、クライアントサイドでのルーティングでは想定通りの動きとなりそうです。

ただ、直接 http://localhost:4800/todo/1 にアクセスしたり、ログイン前に ToDo ページに移動するとエラーになってしまいます。

 
そこで、Next.js で404を表示できるようにします。
Advanced Features: Custom Error Page | Next.js

まずは、独自の 404 ページ (pages/404.js) を用意します。

export default function Custom404() {
  return <h1>404 - Page Not Found</h1>
}

 
次に、ToDoページの getServerSidePropssession がなければ、404ページを表示させます。

なお、NextAuth.jsで session をサーバーサイドで取得するには、 getSession を使います。 getSession() | Client API | NextAuth.js

import {getSession, useSession} from 'next-auth/client';

export async function getServerSideProps(context) {
  const session = await getSession(context);
  if (!session) {
    return {
      notFound: true
    };
  }

  return {
    props: {}
  }
}

 
以上で実装が終わりました。

 

動作確認

一通りできたため、ログインユーザーごとの表示を確認します。

 

admin

 

manager

 

staff

 

その他

 

できていないこと

ログイン後、ToDoページに直接アクセスした場合はエラーになります。

とはいえ、今回は CASL を使うのがメインなので、あきらめます。。。

 

ソースコード

Githubに上げました。
https://github.com/thinkAmi-sandbox/nextjs_casl-sample

Next.js と express-openid-connect を使った Relying Party を TypeScript 化してみた

前回、Next.js と express-openid-connect を使った Relying Party を書きました。
Next.js と express-openid-connect を使って、認証が必要/不要な各ページを持つ Relying Party を作ってみた - メモ的な思考的な

 
上記の記事では JavaScript で実装していましたが、せっかくなので TypeScript 化することにしました。

ただ、TypeScript 化する中で色々悩んだことがあったため、メモを残します。

 
目次

 

環境

実装すること

  • Next.js と express-openid-connect を使った Relying Party を TypeScript 化
  • TypeScript化に合わせ、ESLint設定を変更

 

実装しないこと

  • Prettier を入れる

 

構成

Relying PartyをTypeScript化したくらいで、前回と変わりません。

  • macOS
  • Open ID Provider
    • 前回のものを流用
    • localhost:3780 で起動
    • Rails 6.1.4
    • doorkeeper 5.5.2
    • doorkeeper-openid_connect 1.8.0
    • devise 4.8.0
  • Relying Party
    • localhost:3784 で起動
    • Next.js 11.1.2
    • express 4.17.1
      • Next.js の Custom Server 機能として使用
    • express-openid-connect 2.5.0
    • redis 3.1.2
    • connect-redis 6.0.0
    • セッションストア: Redis
      • Dockerで作成し、ポート 16380 で起動

 

実装

Next.js を TypeScript 化

公式ドキュメントに従い、Next.js を TypeScript 化します。
Existing projects | Basic Features: TypeScript | Next.js

 
今回はすでに JavaScript で作った Next.js アプリがあるため、 Existing projects に従って作業することにします。

そこで、

  1. tsconfig.json を作成
  2. Next.js を起動

したところ、エラーになりました。

# tsconfig.json を作成する
% touch tsconfig.json


# 起動するとエラーになる
% yarn run dev
...
It looks like you're trying to use TypeScript but do not have the required package(s) installed.

Please install typescript and @types/react by running:

        yarn add --dev typescript @types/react

If you are not trying to use TypeScript, please remove the tsconfig.json file from your package root (and any TypeScript files in your pages directory).

 
エラーメッセージによるとパッケージが足りていないようなので、インストールします。

% yarn add --dev typescript @types/react

 
再度 yarn run dev すると、起動しました。

% yarn run dev                          
...
We detected TypeScript in your project and created a tsconfig.json file for you.

event - compiled successfully
> Ready on http://localhost:3784

 
起動後ディレクトリを見たところ、ファイル next-env.d.ts ができていました。公式ドキュメントによると

A file named next-env.d.ts will be created in the root of your project. This file ensures Next.js types are picked up by the TypeScript compiler. You cannot remove it or edit it as it can change at any time.

https://nextjs.org/docs/basic-features/typescript#existing-projects

のようです。
next-env.d.tsの意味 - Qiita

 

不足しているパッケージの追加と .eslintrc.json の更新

公式ドキュメントの

You're now ready to start converting files from .js to .tsx and leveraging the benefits of TypeScript!.

に従い、 pages ディレクトリにある各コンポーネントtsx 化していきます。

 
ためしに、 404.js404.tsx にしたところ

Error: Failed to load parser '@typescript-eslint/parser' declared in '.eslintrc.json » eslint-config-next/core-web-vitals » path/to/nextjs_relying_party_with_public_page/node_modules/eslint-config-next/index.tsx#overrides[0]': Cannot find module 'typescript'

というエラーが出ました。

そこで、まずは不足しているパッケージを追加します。

# ESLintまわり
% yarn add -D @typescript-eslint/parser @typescript-eslint/eslint-plugin

# 使用中のパッケージの型定義を追加
% yarn add -D @types/express @types/redis @types/connect-redis

 
.eslintrc.json を書き換えます。

{
  "parser": "@typescript-eslint/parser",
  "plugins": ["@typescript-eslint"],
  "extends": [
    "next/core-web-vitals",
    "eslint:recommended",
    "plugin:@typescript-eslint/recommended"
  ]
}

 

pagesディレクトリの各コンポーネントtsx

準備ができたので、順番に tsx 化していきます。

 

404.tsx

こちらは戻り値の型 JSX.Element を追加します。

export default function Custom404(): JSX.Element {
  return <h1>404 - Page Not Found</h1>
}

 
なお、 JSX.Element は型定義を見ると、

# https://github.com/DefinitelyTyped/DefinitelyTyped/blob/c20bff8d46570278a10ec76102af314d1cdca6e3/types/react/index.d.ts#L3083

declare global {
    namespace JSX {
        interface Element extends React.ReactElement<any, any> { }
// ...

でした。

JSX.ElementReact.ReactElement などの用語は、以下が参考になりました。
Reactのコンポーネント周りの用語を整理する | blog.ojisan.io

 

_app.tsx

こちらは公式ドキュメント通りに変更します。
Custom App | Basic Features: TypeScript | Next.js

import '../styles/globals.css'
import {AppProps} from "next/app";

function MyApp({ Component, pageProps }: AppProps): JSX.Element {
  return <Component {...pageProps} />
}

export default MyApp

 

index.tsx

このファイルでは getServerSideProps を使っています。

そのため、まずは getServerSideProps 部分をTypeScript化します。

getServerSideProps 部分は特に変更していないため、公式ドキュメント通りの型 GetServerSideProps を指定します。
Static Generation and Server-side Rendering | Basic Features: TypeScript | Next.js

import {GetServerSideProps} from "next";

export const getServerSideProps: GetServerSideProps = async function () {
  return {
    props: {
      host: process.env.NEXT_HOST,
      port: process.env.PORT,
    }
  }
}

 
続いて、コンポーネント部分をTypeScript化します。

getServerSideProps で取得した process.env の各値はどんな型かを調べたところ、

# https://github.com/DefinitelyTyped/DefinitelyTyped/blob/6bf1eeabf96a96d292a37797f21961a9003bb719/types/node/process.d.ts#L107

interface ProcessEnv extends Dict<string> {
    /**
      * Can be used to change the default timezone at runtime
      */
    TZ?: string;
}

とありました。

なお、型を再定義したほうが扱いやすいようですが、今回はサンプルなので特に変更しません。

 
型が分かったので、型を定義します。

type Props = {
  host: string
  port: string
}

export default function Home({ host, port }: Props): JSX.Element {
  return (
    <>
      <h1>Index page</h1>
      <a href={`${host}:${port}/profile`}>Go</a>
    </>
  )
}

 

profile.tsx

getServerSideProps の戻り値の型定義

こちらも getServerSideProps の戻り値の型定義から見ていきます。

index.tsx と異なり、

export const getServerSideProps = async function ({ req, res }: Args): Promise<ReturnValues> {
  if (!req.oidc.isAuthenticated()) {
// ...
    if (!res.oidc.errorOnRequiredAuth && req.accepts('html')) {
      await res.oidc.login()
      return {
        props: {
          email: ''
        }
      }
    }
    return {
      notFound: true
    }
  }

// ...

のように、異なる型で値が返ってくるため、 GetServerSideProps が使えなさそうでした。

 
そこで、TypeScriptの合併型 (Union型)にて、戻り値の型を定義します。

type ReturnValues = {
  props: {
    email: string
  }
} | {
  notFound: boolean
}

 
あとは async な関数なので、Promise<T> を使うことで、

export const getServerSideProps = async function ({ req, res }): Promise<ReturnValues> {

となりました。

 

getServerSideProps の引数の型定義

型定義をするため、必要な情報を調べます。

引数 reqres は関数の内部で

export const getServerSideProps = async function ({ req, res }: Args): Promise<ReturnValues> {
  if (!req.oidc.isAuthenticated()) {
// ...

と、 req.oidc.isAuthenticated() のような使い方をしています。

 
reqres の属性 oidcexpress-openid-connect で追加されています。

そこで、express-openid-connect の型定義を見ると、 req

interface OpenidRequest extends Request {
  /**
   * Library namespace for authentication methods and data.
   */
  oidc: RequestContext;
}

でした。
https://github.com/auth0/express-openid-connect/blob/v2.5.0/index.d.ts#L40

 
続けて、 oidcisAuthenticatedOpenidRequest 型の中で定義されていました。
https://github.com/auth0/express-openid-connect/blob/v2.5.0/index.d.ts#L85

interface RequestContext {
  /**
   * Method to check the user's authenticated state, returns `true` if logged in.
   */
  isAuthenticated: () => boolean;
// ...

 
一方、 res

interface OpenidResponse extends Response {
  /**
   * Library namespace for authentication methods and data.
   */
  oidc: ResponseContext;
}

でした。
https://github.com/auth0/express-openid-connect/blob/v2.5.0/index.d.ts#L61

 
そこで、

import {OpenidRequest, OpenidResponse} from "express-openid-connect";

type Args = {
  req: OpenidRequest
  res: OpenidResponse
}

という型を定義することで

export const getServerSideProps = async function ({ req, res }: Args): Promise<ReturnValues> {

となりました。

 

ちなみに:引数の分割代入に対する型指定をワンライナーで書く場合

ちなみに、

type Args = {
  req: OpenidRequest
  res: OpenidResponse
}

export const getServerSideProps = async function ({ req, res }: Args): Promise<ReturnValues> {}

ワンライナーで書く場合は

expeort const getServerSideProps = async function ({ req, res }: {req: OpenidRequest, res: OpenidResponse}): Promise<ReturnValues> {}

となります。
TypeScript - TypeScriptで関数の引数に分割代入したときの型指定方法|teratail

 

express-openid-connectの型定義 ResponseContext をアンビエント宣言で拡張

ふとコードを見てみると、

と、

TS2339: Property 'errorOnRequiredAuth' does not exist on type 'ResponseContext'.

というエラーが出ていました。

 
ResponseContextの型定義を見てみると、

interface ResponseContext {
  login: (opts?: LoginOptions) => Promise<void>;
  logout: (opts?: LogoutOptions) => Promise<void>;
}

と、 errorOnRequiredAuth がありませんでした。
https://github.com/auth0/express-openid-connect/blob/v2.5.0/index.d.ts#L147

ただ、 errorOnRequiredAuthミドルウェア requiresAuth で使っているものを移植しただけなので、型定義に errorOnRequiredAuth を追加しても問題無さそうでした。
https://github.com/auth0/express-openid-connect/blob/v2.5.0/middleware/requiresAuth.js#L20

 
そこで、以下を参考にアンビエント宣言で errorOnRequiredAuth を型定義に追加します。

 
なお、以下により、アンビエント宣言だけを行い、 typeRoots での設定は行いません。

 
上記で見た通り ResponseContextinterface だったため、types/express-openid-connect/index.d.ts ファイルを用意し、アンビエント宣言します。

import 'express-openid-connect'

declare module 'express-openid-connect' {
  interface ResponseContext {
    errorOnRequiredAuth? :boolean
  }
}

 
すると、エラーが無くなりました。

 

コンポーネントの型定義

こちらは今まで通りです。

 type Props = {
  email: string
}

export default function Profile({ email }: Props): JSX.Element {
  return (
    <>
      <h1>Your Profile</h1>
      <p>Email: {email}</p>

      <Link href="/" passHref>
        <button>Go Home</button>
      </Link>
    </>
  )
}

 

カスタムサーバの server.js を TypeScript 化

続いて、カスタムサーバ部分を TypeScript 化します。

TypeScript化で影響のあったところは、

  • RedisClientまわり
  • server.listen()時のエラーハンドリングまわり

でした。

後者については、以下を参考に server.on('error') の構文に差し替えました。

import express from 'express'
import { auth } from 'express-openid-connect'
import  next from 'next'
// Redisの設定
import redis from 'redis'
import connectRedis from 'connect-redis'
const RedisStore = connectRedis(auth)

const dev = process.env.NODE_ENV !== 'production'

const app = next({ dev })
const handle = app.getRequestHandler()

app.prepare().then(() => {
  const server = express();
  const port = parseInt(process.env.PORT, 10)

  // Redisクライアントを定義
  // TypeScriptでのエラーが出るため、undefined にならないようにしておく
  const redisPort = process.env.REDIS_PORT || '16380'
  const redisClient = redis.createClient({
    host: process.env.REDIS_HOST,
    port: parseInt(redisPort, 10),
  });

  // express-openid-connectの設定を追加
  server.use(auth({
    issuerBaseURL: process.env.OP_BASE_URL,
    baseURL: `${process.env.NEXT_HOST}:${process.env.PORT}`,
    clientID: process.env.CLIENT_ID_OF_MY_OP,
    clientSecret: process.env.CLIENT_SECRET_OF_MY_OP,
    secret: process.env.SECRET_OF_OIDC,
    authorizationParams: {
      response_type: 'code',
      scope: 'openid',
    },
    session: {
      name: 'sessionOfExpressPublic', // sessionの名前を変える
      store: new RedisStore({client: redisClient}) // セッションストアをRedisにする
    },
    authRequired: false,
  }));

  server.all('*', (req, res) => {
    return handle(req, res)
  })

  server.listen(port, () => {
    console.log(`> Ready on http://localhost:${port}`)
  }).on('error', function(e) {
    console.log('Error happened: ', e.message)
  })
})

 

tsconfigの strict を true にした時の対応

ここまでは tsconfig の compilerOptions において、 strictfalse でした。

厳密にやっても問題ないだろうと考え、 stricttrue にしたところ、 server.ts にて以下のエラーが出ました。

 
エラーになった箇所ですが、 express-openid-connect の example からそのまま持ってきている実装でした。
https://github.com/auth0/express-openid-connect/blob/master/EXAMPLES.md#9-use-a-custom-session-store

また、リポジトリを見るとissueが上がっていました。
TypeScript errors when defining custom session store · Issue #234 · auth0/express-openid-connect

今後改善される可能性があるため、今回は TypeScript 化をあきらめました。

そのため、

session: {
   // ...
  // eslint-disable-next-line @typescript-eslint/ban-ts-comment
  // @ts-ignore
  store: new RedisStore({client: redisClient}) // セッションストアをRedisにする
},

のようにエラーを無視するようにしました。

 
以上で TypeScript 化は終わりました。

 

TypeScriptのままで動作させる

今の状態でコンパイルして Next.js を動作するようにもできますが、ローカルで開発するのには手間なので、TypeScript のままで動作させたくなりました。

方法を探したところ、

  • ts-node
  • ts-node-dev
  • nodemon

あたりが見つかりました。

 
ただ、 ts-node-dev を使ってみたところ、

TypeError: Unexpected response from worker: undefined
    at ChildProcessWorker._onMessage (path/to/nextjs_relying_party_with_public_page/node_modules/jest-worker/build/workers/ChildProcessWorker.js:264:15)
    at ChildProcess.emit (events.js:327:22)
    at emit (internal/child_process.js:903:12)
    at processTicksAndRejections (internal/process/task_queues.js:81:21)
[ERROR] 15:42:30 TypeError: Unexpected response from worker: undefined

というエラーが発生しました。

 
ts-node-devリポジトリを見ると、同じ現象のissueがありました。
tsnd fails to run jest-worker with enableWorkerThreads: false · Issue #255 · wclr/ts-node-dev

issueのコメントに

The only solution that I found is to use node-dev directly. Fortunately, they introduced Typescript support out of the box so I don't need ts-node-dev anymore.

とあったため、TypeScript化した Next.js では使うのが難しいのかなと思いました。

 
ここで、改めてTypeScriptを使う公式サンプルを見たところ、 ts-node + nodemon を使っていました。
https://github.com/vercel/next.js/blob/canary/examples/custom-server-typescript/package.json

 
そこで今回は Next.js のサンプルや以下の記事を参考に、ts-node + nodemon で書いてみます。
Next.jsをExpressカスタムサーバ + TypeScriptで動かす設定 - Qiita

 

tsconfig.server.json の追加

参考 Qiita のままで問題ないです。

{
  "extends": "./tsconfig.json", // tsconfig.jsonの設定を継承する
  "compilerOptions": {
    "module": "commonjs", // Next.jsとExpressの両方を連携させるために、commmonjsを利用する
    "outDir": "dist", // ビルドファイルの出力先
    "noEmit": false // Next.jsはBabelを使用してTypeScriptをコンパイルするので、TSコンパイラはjsを出力しない。設定を上書きする。
  },
  "include": ["server"] // TSコンパイラにserverディレクトリのみをコンパイル対象として認識させる。
}

 

nodemon.json の追加

こちらは公式サンプルの通りです。

{
  "watch": ["server"],
  "exec": "ts-node --project tsconfig.server.json server/index.ts",
  "ext": "js ts"
}

 

package.json の修正

nodemon で動くよう、 scriptsdev を修正します。

{
  "name": "nextjs_relying_party_with_public_page",
// ...
  "scripts": {
    "dev": "nodemon",
// ...

 

動作確認

yarn run dev し、今まで通り http://localhost:3784/ へアクセスできればOKです。

 

ソースコード

Githubに上げました。
https://github.com/thinkAmi-sandbox/oidc_op_rp-sample

 
今回のPRはこちらです。
https://github.com/thinkAmi-sandbox/oidc_op_rp-sample/pull/7

Next.js と express-openid-connect を使って、認証が必要/不要な各ページを持つ Relying Party を作ってみた

前回、Next.js + express-openid-connect を使って、全てのページでOpenID Connectによる認証が必要な Relying Party を作りました。
Next.js + express-openid-connect を使って、バックエンドで OpenID Provider と通信する Relying Party を作ってみた - メモ的な思考的な

そんな中、上記の Relying Party に認証が不要なページも追加してみたところ、いくつか悩んだところがあったため、メモを残します。

 
なお、「この実装で良い」と言い切れない部分があるので、もしより良い実装があれば教えていただけるとありがたいです。

 
目次

 

環境

実装すること

  • ベースは、前回作成した RP
  • 認証が必要なページと不要なページを用意
    • /
      • 認証不要
    • /profile
      • 認証必要

 

構成

  • macOS
  • Open ID Provider
    • 前回のものを流用
    • localhost:3780 で起動
    • Rails 6.1.4
    • doorkeeper 5.5.2
    • doorkeeper-openid_connect 1.8.0
    • devise 4.8.0
  • Relying Party
    • localhost:3784 で起動
    • Next.js 11.1.2
    • express 4.17.1
      • Next.js の Custom Server 機能として使用
    • express-openid-connect 2.5.0
    • redis 3.1.2
    • connect-redis 6.0.0
    • セッションストア: Redis
      • Dockerで作成し、ポート 16380 で起動

 

失敗

express-openid-connect の 例を見ると、 requiresAuth() を使えば 認証が必要/不要なページを制御できそうでした。
2. Require authentication for specific routes | express-openid-connect/EXAMPLES.md at master · auth0/express-openid-connect

 
そこで、 server.js に対し、

app.prepare().then(() => {
  const server = express();
  // ...
  server.use(auth({
    // ...
    // 以下を追加し、指定したルートで認証の要不要を指定
    authRequired: false,
  }));

  // `/` は認証不要
  server.get('/', (req, res) => {
    return handle(req, res)
  })

  // 上記以外は認証必要
  server.all('*', requiresAuth(), (req, res) => {
    return handle(req, res)
  })

として、Next.js を起動しました (このコミット)。

 
すると、 / にアクセスした時に

のようなエラーがChromeのConsoleに表示されました。

 
エラーは

# まとめあげ
Refused to execute script from '<URL>' because its MIME type ('text/html') is not executable, and strict MIME type checking is enabled.

# 詳細
Refused to execute script from 'http://localhost:3780/users/sign_in' because its MIME type ('text/html') is not executable, and strict MIME type checking is enabled.

であり、ワーニングは

# まとめあげ
Cross-Origin Read Blocking (CORB) blocked cross-origin response <URL> with MIME type text/html. See <URL> for more details.

# 詳細
Cross-Origin Read Blocking (CORB) blocked cross-origin response http://localhost:3780/users/sign_in with MIME type text/html. See https://www.chromestatus.com/feature/5629709824032768 for more details.

でした。

 
また、OPのログを見てみると、 / を開いた瞬間に、OPの認可エンドポイントへリクエストが飛んでいました。

Started GET "/oauth/authorize?
client_id=***
&scope=openid
&response_type=code
&redirect_uri=http%3A%2F%2Flocalhost%3A3784%2Fcallback
&nonce=***
&code_challenge_method=S256&code_challenge=***"
 for 127.0.0.1 at 2021-09-09 21:04:39 +0900

 
一方、 /profile へ直接アクセスすると、OPのログイン画面が表示されました。ログイン後は //profile にアクセスしても問題ありませんでした。

 

対応

今回、 express-openid-connectミドルウェアとして express に組み込んでいるため、ミドルウェアまわりの処理がうまくいっていないように見えました。

そこで、 requiresAuth() を使うのではなく、 requiresAuth() 相当の処理を Next.js の getServerSideProps へ実装することにしました。

なお、Next.js のドキュメントには

Note: You can import modules in top-level scope for use in getServerSideProps. Imports used in getServerSideProps will not be bundled for the client-side. This means you can write server-side code directly in getServerSideProps. This includes reading from the filesystem or a database.

https://nextjs.org/docs/basic-features/data-fetching#getserversideprops-server-side-rendering

とあったため、今回は /profile のページの getServerSideProps に直接書くこととしました。

 

req.oidc.isAuthenticated の追加

express-openid-connectソースコードを読むと、 req.oidc.isAuthenticated にて認証済か否かを判断していました。

 
そこで、

import Link from 'next/link';

export const getServerSideProps = async function ({ req, res }) {
  // requireAuth() の中身を移植
  if (!req.oidc.isAuthenticated()) {
    return {
      redirect: {
        // 途中のエラーが表示される
        destination: '/login',
        permanent: false
      },
    }
  }

  // 以降は同じ
  return await req.oidc.fetchUserInfo()
    .then(response => {
      return {
        props: {
          email: response.email
        }
      }
    })
}

export default function Profile({ email }) {
  return (
    <>
      <h1>Your Profile</h1>
      <p>Email: {email}</p>

      <Link href="/" passHref>
        <button>Go Home</button>
      </Link>
    </>
  )
}

と、認証されていない !req.oidc.isAuthenticated 時は Next.js でログインページへリダイレクトするようにしてみました (このコミット)。

 
すると、 / にアクセスしてもエラーが表示されなくなりました。

続いて /profile へ遷移するボタンをクリックしたところ、ログインページは表示されるようになったものの、一瞬エラーページが表示されました。

Chrome の Network を見てみると、HTTP 404 となっていました。

 
詳細を確認すると、クライアントサイドでルーティングしたことにより、 HTTP 404 が表示されているようでした。

 
そこで、サーバサイドでルーティングするよう、ホストとポートを指定してみました (このコミット)。

export const getServerSideProps = async function ({ req, res }) {
  if (!req.oidc.isAuthenticated()) {
    return {
      redirect: {
        // destination: '/login',
        // Next.js の外としてルーティング
        destination: `${process.env.NEXT_HOST}:${process.env.PORT}/login`,
        permanent: false
      },
    }
  }
// ...

 
すると、クライアントサイドではルーティングされなくなり、HTTP 404 ページも表示されなくなりました。

 
ただ、 /profile へ遷移した後にログインしたのに、ログイン後は / に戻されてしまいました。

 

res.oidc.login の追加

express-openid-connectソースコードをさらに読むと、 res.oidc.login を使ってログイン処理を行っていました。
https://github.com/auth0/express-openid-connect/blob/v2.5.0/middleware/requiresAuth.js#L20

 
そこで、

export const getServerSideProps = async function ({ req, res }) {
  if (!req.oidc.isAuthenticated()) {
    // 追加
    if (!res.oidc.errorOnRequiredAuth && req.accepts('html')) {
      await res.oidc.login()
      return {
        props: {
          email: ''
        }
      }
    }
    return {
      notFound: true
    }
  }

と、 res.oidc.login を使うようにしました。

また、 !res.oidc.errorOnRequiredAuth && req.accepts('html') を満たさない場合は HTTP 404 ページを表示させるため、Next.js 10 で追加された notFound を使うようにしました。
notFound Support | Blog - Next.js 10 | Next.js

それに合わせて、カスタムの 404 ページを pages/404.js として用意しました(ここまででこのコミット)。
Customizing The 404 Page | Advanced Features: Custom Error Page | Next.js

export default function Custom404() {
  return <h1>404 - Page Not Found</h1>
}

 
準備ができたため、

  • / へアクセス
  • Go Profile ボタンをクリック
  • OPのログイン画面でログイン

の順で操作したところ、ログイン後は /profile へと遷移するようになりました。

 
ただ、Chrome の Network タブを見たところ

と、 CORS error: Cross-Origin Resource Sharing error: MissingAllowOriginHeader エラーが表示されていました。

 

クライアントサイドでの /profile への遷移をあきらめる

デバッグしてみたところ、 res.oidc.login() の処理後に CORS エラーが出ていました。

とはいえ、res.oidc.login() の削除もできません。

そこで、pages/index.js の Link コンポーネントによるクライアントサイドでの遷移を、 <a> タグでの遷移に切り替えました (このコミット)。
Linking between pages | Routing: Introduction | Next.js

export const getServerSideProps = async function ({ req, res }) {
  return {
    props: {
      host: process.env.NEXT_HOST,
      port: process.env.PORT,
    }
  }
}


export default function Home({ host, port }) {
  return (
    <>
      <h1>Index page</h1>

      {/* クライアントでのルーティングができないので、aタグに差し替え */}
      <a href={`${host}:${port}/profile`}>Go</a>
    </>
  )
}

 
再度実行したところ、CORSエラーが出なくなりました。

 
これで、認証が必要/不要な各ページを持つ Relying Party が実装できました。

 

ソースコード

Githubに上げました。
https://github.com/thinkAmi-sandbox/oidc_op_rp-sample

今回のPRはこちらです。
https://github.com/thinkAmi-sandbox/oidc_op_rp-sample/pull/6

Next.js + express-openid-connect を使って、バックエンドで OpenID Provider と通信する Relying Party を作ってみた

以前、Railsを使って OpenID Connectの Relying Party (RP) を作りました。
Railsとdoorkeeper-openid_connectやOmniAuth を使って、OpenID Connectの OpenID Provider と Relying Party を作ってみた - メモ的な思考的な

 
そんな中、Next.jsを使う機会があったため、せっかくなので Next.js でも OpenID Connect の RP を作ってみたくなりました。

Next.jsを使う場合、

  • フロントエンドをRPにする
  • バックエンドをRPにする

のどちらかを選べそうでした。

現時点ではまだ Next.js に詳しくないため、今回はバックエンドをRPにしてみることにしました。

ただ、作る中で色々考えたことがあったため、メモを残します。

 

目次

 

環境

実装すること

  • OpenID Connectのフローは 認可コードフロー のみ実装
  • RPで認証結果を保持するために、以前同様セッションCookieを使う
  • セッションCookieのストレージは Redis を使う
    • 前回は Active Record を使っており、別のストレージを使いたくなったため
  • 以前同様のセキュリティ対策は行う
    • statePKCEnonce
    • もしライブラリ側で行っていれば、どこで行っているかを確認する
  • Next.jsでは、すべてのパスはログイン必須とする
    • 今回用意するパスは以下の2つ
      • /
      • /profile

 

実装しないこと

  • 以前同様
    • OPの同意画面で「Deny」をクリックしたときの動作
    • アクセストークンの JWT 化
    • ログアウトまわり
    • OPの属性を修正したら、RPの属性も同期して修正

 

構成

  • macOS
  • Open ID Provider
    • 前回のものを流用
      • localhost:3780 で起動
      • Rails 6.1.4
      • doorkeeper 5.5.2
      • doorkeeper-openid_connect 1.8.0
      • devise 4.8.0
  • Relying Party
  • セッションストア: Redis
    • Dockerで作成し、ポート 16379 で起動

 

調査

Next.js で OIDC の RP を作るのに使えそうな npm パッケージを調べてみました。

 

NextAuth.js

「Authentication for Next.js」として NextAuth.js がありました。
nextauthjs/next-auth: Authentication for Next.js

ビルトインのサポートとしては、READMEに

OAuth 1.0, 1.0A and 2.0

とありました。

また、issueを見たところ、

We don't explicitly have built-in OpenID Connect support currently, but we do have built-in support OAuth providers that support OpenID Connect, such as Google and Apple, Auth0 and others.

OpenID Connect support? · Issue #250 · nextauthjs/next-auth

とありました。

そのため、NextAuthで OIDC の RP を作りたければ自分で拡張すればよさそうでした。

 
一方、NextAuthでサポートしているストレージは、READMEによると

Built-in support for MySQL, MariaDB, Postgres, Microsoft SQL Server, MongoDB and SQLite

と、RDBのみでした。

Redisなどのインメモリデータベースが使えるかどうかを調べたところ、issueに

MySQL, Postgres, Microsoft SQL Server and MongoDB are supported. If you want to use a Redis database you would need to write your own adapter. Redis would be a really unconventional choice in a context like this and not something support is planned for.

redis adapter · Issue #544 · nextauthjs/next-auth

とありました。

また、コミュニティのデータストアアダプタを見たところ、DynamoDBはあったもののRedisはありませんでした。
nextauthjs/adapters: next-auth adapters maintained by the community to support any database.

他に、NextAuthのTwitterを見たところ

とありました。

今後いろいろ改善されていきそうでしたが、今回はRedisを使いたかったため、他の npm パッケージも探してみることにしました。

 

express-openid-connect

Next.js にある Custom Server 機能を使うことにより、Next.js のバックエンドを express に差し替えられそうでした。
Advanced Features: Custom Server | Next.js

Custom Server のドキュメントの注意書きには

Note: A custom server can not be deployed on Vercel.

とあるものの、もし Vercel にデプロイしないのであれば、 express の資産を使えそうなのが魅力的でした。

 
また、OpenID Connect の npm パッケージを調べると、 node-openid-client が気になりました。
panva/node-openid-client: OpenID Certified™ Relying Party (OpenID Connect/OAuth 2.0 Client) implementation for Node.js.

READMEに

Filip Skokan has certified that openid-client conforms to the following profiles of the OpenID Connect™ protocol

とあったためです。

 
そこで、 node-openid-client を組み込んでいて express でも動く npm パッケージを探したところ、 express-openid-connect がありました。
auth0/express-openid-connect: An Express.js middleware to protect OpenID Connect web applications.

Auth0のプロジェクトであったため、Auth0で使うものなのかなと思いましたが、READMEを読む限り他のOPでも使えそうでした。

また、Next.js向けについてはissueがあり

We don't have NestJS support in our roadmap currently, but we are always reviewing this based on demand.

Suggestion: add nestjs support · Issue #221 · auth0/express-openid-connect

と書かれていました。

 
ただ、Next.js の Custome Server として express を使った時も使えるかどうかは分かりませんでした。

そこで、 express-openid-connect を使って RP を作ってみることにしました。

 

Relying Partyの作成

各種インストール

まずは create-next-app します。

% npx create-next-app nextjs_relying_party_of_express_with_all_required
...
Success! Created nextjs_relying_party_of_express_with_all_required at path/to/nextjs_relying_party_of_express_with_all_required
Inside that directory, you can run several commands:

  yarn dev
    Starts the development server.

  yarn build
    Builds the app for production.

  yarn start
    Runs the built app in production mode.

We suggest that you begin by typing:

  cd nextjs_relying_party_of_express_with_all_required
  yarn dev

 
続いて必要な npm パッケージを追加します。

手元では yarn を使っていたため、yarn でインストールします。

% cd nextjs_relying_party_of_express_with_all_required

% yarn add express express-openid-connect redis connect-redis

 

Next.js を Custom Server の express へ差し替え

今回は express へ差し替えます。

まずは、公式の examples の server.js を元に、ポートだけ修正したものを用意します。
https://github.com/vercel/next.js/blob/canary/examples/custom-server-express/server.js

// server.js
const express = require('express')
const next = require('next')

const port = parseInt(process.env.PORT, 10) || 3783
const dev = process.env.NODE_ENV !== 'production'
const app = next({ dev })
const handle = app.getRequestHandler()

app.prepare().then(() => {
  const server = express()

  server.all('*', (req, res) => {
    return handle(req, res)
  })

  server.listen(port, (err) => {
    if (err) throw err
    console.log(`> Ready on http://localhost:${port}`)
  })
})

 
続いて、 package.json を修正し、 dev の場合は express で起動するように修正します。

合わせて、デバッグ起動できるように package.json を編集します。 Express のデバッグ

"dev": "DEBUG=express:* node server.js",

 
差し替えが終わったので動作確認します。

% yarn run dev

で起動後、 http://localhost:3783/ にアクセスして Next.js の画面が表示されればOKです。

 

セッションストアのRedisを用意

今回はDockerでRedisを用意します。

docker compose で起動するよう、 docker-compose.yml ファイルを用意します。

この時、expressからはポート 16379 で接続できるようにします。

version: '3'
services:
  redis:
    image: "redis:latest"
    ports:
      - "16379:6379"
    volumes:
      - "./data/redis:/data"

 
Redisを起動しておきます。

% docker-compose up -d

 

.env.local を用意

今回のアプリでは、OPのホスト名などは環境変数から読み込むようにします。

そこで、 Next.js で環境変数を設定するファイル .env.local を用意します。
Basic Features: Environment Variables | Next.js

# OPからもらうクライアントID
CLIENT_ID_OF_MY_OP=<OPからもらった値>

# OPからもらうクライアントシークレット
CLIENT_SECRET_OF_MY_OP=<OPからもらった値>

# シークレット
SECRET_OF_OIDC=<任意の値>

# OPホスト
OP_BASE_URL=http://localhost:3780

# UserInfoエンドポイント
USERINFO_ENDPOINT=$OP_BASE_URL/oauth/userinfo

# RPのホスト
NEXT_HOST=http://localhost

# RPのポート
PORT=3783

# セッションストレージ用Redisのホストとポート
REDIS_HOST=localhost
REDIS_PORT=16379

 

express-openid-connect を express のミドルウェアとして追加

続いて、expressのミドルウェアexpress-openid-connect を追加します。

APIドキュメントを参考に、 auth の設定を行います。
ConfigParams | express-openid-connect

const { auth } = require('express-openid-connect');
// ...
app.prepare().then(() => {
  // ...
  // express-openid-connectの設定を追加
  server.use(auth({
    issuerBaseURL: process.env.OP_BASE_URL,
    baseURL: `${process.env.NEXT_HOST}:${process.env.PORT}`,
    clientID: process.env.CLIENT_ID_OF_MY_OP,
    clientSecret: process.env.CLIENT_SECRET_OF_MY_OP,
    secret: process.env.SECRET_OF_OIDC,
    authorizationParams: {
      response_type: 'code',
      scope: 'openid',
    },
// ...

 

express-openid-connectでのIDトークン検証の有無について

以前、RubyのOmniAuthで実装した時は、IDトークンの検証は自分でやる必要がありました。

express-openid-connectの場合はどうかを調べたところ、

I can confirm that the ID Token's signature is verified before the afterCallback hook is called

Insecure example in the documentation? · Issue #206 · auth0/express-openid-connect

と書かれていました。

 
また、認証レスポンスを受け取るリダイレクトURIのパス ( localhost:3783/callback ) では、

  1. https://github.com/auth0/express-openid-connect/blob/v2.5.0/middleware/auth.js#L62
  2. https://github.com/auth0/express-openid-connect/blob/v2.5.0/middleware/auth.js#L113
  3. https://github.com/panva/node-openid-client/blob/v4.7.5/lib/client.js#L372

の順で呼ばれ、 node-openid-client 側でIDトークンの検証が行われていました。

デバッガを使っても、 node-openid-client 側の callback の処理で止まりました。

 
そのため、IDトークンの検証は自分で行わなくても良さそうでした。

なお、もし認識に誤りがありましたら、ご指摘ください。

 

セッションストレージとして Redis を追加

auth の属性 session を使い、セッションストレージとして Redis を設定します。

なお、Redisクライアントは公式ドキュメントを参考に設定します。
Redis with Node.js (node_redis) | Redis Documentation Center

app.prepare().then(() => {
// ...
  // Redisクライアントを定義
  const redisClient = redis.createClient({
    host: process.env.REDIS_HOST,
    port: parseInt(process.env.REDIS_PORT, 10),
  });

  // express-openid-connectの設定を追加
  server.use(auth({ ... },

    // 追加
    session: {
      name: 'sessionOfExpressJs', // sessionの名前を変える
      store: new RedisStore({client: redisClient}) // セッションストアをRedisにする
    }
  }));

 
ちなみに、 .env.local の値を読み込む場所には

This loads process.env.DB_HOST, process.env.DB_USER, and process.env.DB_PASS into the Node.js environment automatically allowing you to use them in Next.js data fetching methods and API routes.

Loading Environment Variables | Basic Features: Environment Variables | Next.js

のような制限があります。

そのため、

const redisClient = redis.createClient({
  host: process.env.REDIS_HOST,
  port: parseInt(process.env.REDIS_PORT, 10),
});

app.prepare().then(() => {
// ...
}

のように定義すると、

...
info  - Loaded env from path/to/nextjs_relying_party_of_express_with_all_required/.env.local
events.js:292
      throw er; // Unhandled 'error' event
      ^

Error: connect ECONNREFUSED 127.0.0.1:6379

と、 .env.local から値は読み込まれず、デフォルト値がそのまま使われてしまいます。

 

Next.js の page を作成

今回のpageとして

  • /
    • トップページ
    • profil への遷移ボタンあり
  • /profile
    • UserInfoエンドポイントから取得したメールアドレスを表示
    • index への遷移ボタンあり

の2つを用意します。

 

pages/index.js

トップページには、 /profile へ遷移するためのボタンを Link コンポーネント<button> を組み合わせて使います。

なお、Link コンポーネント<button> を組み合わせる場合は、以下にある通り passHref を使います。

import Link from 'next/link'

export default function Home() {
  return (
    <>
      <h1>Index page</h1>
      <Link href="/profile" passHref>
        <button>Go Profile</button>
      </Link>
    </>
  )
}

 

pages/profile.js

UserInfoエンドポイントへのリクエストは express-openid-connect で用意している方法を使います。
https://github.com/auth0/express-openid-connect/blob/master/EXAMPLES.md#6-calling-userinfo

なお、今回は /profile にアクセスするたびに、 UserInfo エンドポイントへのアクセスが発生する作りとなっています。

import Link from 'next/link';

export const getServerSideProps = async function ({ req, res }) {
  // UserInfoエンドポイントへリクエストを投げて、値を取得する
  // この書き方だとアクセスするたびにUserInfoへリクエストを投げるので注意
  return await req.oidc.fetchUserInfo()
    .then(response => {
      return {
        props: {
          email: response.email
        }
      }
    })
}

export default function Profile({ email }) {
  return (
    <>
      <h1>Your Profile</h1>
      <p>Email: {email}</p>

      <Link href="/" passHref>
        <button>Go Home</button>
      </Link>
    </>
  )
}

 

OP の OAuth Application として RP を登録

OPの http://localhost:3780/oauth/applications/ を開き、今回作成した RP を OAuth Applicationとして登録します。

項目
Name 任意 (next_express_rp など)
Redirect URI http://localhost:3783/callback (express-openid-connectのコールバックURI)
Confidential チェックを入れる
Scopes openid

 

動作確認

未ログインの状態で //profile へアクセスした時の動作を確認します。

 

未ログインで / にアクセスする場合

画面遷移

/ にアクセスしたところ、OPのログイン画面へ遷移します。

 

ログインすると、RPの / へ遷移しました。

 

/profile へ遷移するボタンをクリックすると、emailが表示されました。

 

セッションストアの中身

せっかくなので、セッションストアの中身を確認します。

Jetbrains系IDEで確認しようとしましたが、今のところ公式では Redis の中身を確認する方法がありません。

We are working on our roadmap for 2022.

This is the most upvoted issue in our tracker and thus rather important. However, support for Redis isn't straightforward and requires quite a bit of time and investment. Thus it's really important for us to understand clearly what you would expect in terms of support in DataGrip and other IntelliJ-based IDEs.

Would viewing objects and data, cover the majority of the needs? Do you write queries against your Redis data sources? Is data export crucial in your use-cases?

These answers can help us better understand what would be required on our behalf before we could make any commitment to supporting this feature in the next roadmap.

Redis support : DBE-283

 
そこで、プラグイン redis simple を使って確認します。
redis simple - IntelliJ IDEs Plugin | Marketplace

 
上記画像にある通り一画面におさまらないため、コピペした値を貼り付けてみます。

こんな形式で値が保存されているようです。

{
    "header": {
        "iat": 1630942184,
        "uat": 1630942188,
        "exp": 1631028588
    },
    "data": {
        "access_token": "QQ2GnLHFlqZVrNo-J-ggymFTZRJtQt1CPi6za1P1cnA",
        "token_type": "Bearer",
        "expires_at": 1630942244,
        "scope": "openid",
        "created_at": 1630942184,
        "id_token": "eyJ0e***"
    }
}

 
id_token にはIDトークンが入っているようですので、デコードしてみます。

{
  "iss": "http://localhost:3780",
  "sub": "1",
  "aud": "<OPから払い出されたクライアントID>",
  "exp": 1630942304,
  "iat": 1630942184,
  "nonce": "Hv6NwZUbfeurTX_si_KVQqiesCEZbBuU8B-nHmaeVhI"
}

 

OPのログ

OPのログから、OpenID Connectの一連の流れが確認できました。

# OpenID Connect Discovery 1.0 エンドポイントにて、OPの情報取得
Started GET "/.well-known/openid-configuration" for 127.0.0.1 at 2021-09-07 00:29:39 +0900
...
Completed 200 OK in 2ms (Views: 0.5ms | ActiveRecord: 0.0ms | Allocations: 778)


# 認可エンドポイントにリクエストするが、HTTP 401
Started GET "/oauth/authorize?
client_id=***
&scope=openid
&response_type=code
&redirect_uri=http%3A%2F%2Flocalhost%3A3783%2Fcallback
&nonce=Hv6NwZUbfeurTX_si_KVQqiesCEZbBuU8B-nHmaeVhI
&state=eyJyZXR1cm5UbyI6Ii8ifQ
&code_challenge_method=S256
&code_challenge=Fhixi79s1chqcCcuJBgj2_f-GwsX_lEED6vsft0Rsis" for 127.0.0.1 at 2021-09-07 00:29:39 +0900
...
Completed 401 Unauthorized in 0ms (ActiveRecord: 0.0ms | Allocations: 187)


# ログイン画面の表示
Started GET "/users/sign_in" for 127.0.0.1 at 2021-09-07 00:29:39 +0900
...
Completed 200 OK in 4ms (Views: 3.4ms | ActiveRecord: 0.0ms | Allocations: 1565)


# ログイン実行・成功し、認可エンドポイントへリダイレクト
Started POST "/users/sign_in" for 127.0.0.1 at 2021-09-07 00:29:44 +0900
...
  Parameters:
{
  "authenticity_token"=>"[FILTERED]",
  "user"=>{
    "email"=>"foo@example.com",
    "password"=>"[FILTERED]"},
    "commit"=>"Log in"
  }
}
Redirected to http://localhost:3780/oauth/authorize?***
Completed 302 Found in 241ms (ActiveRecord: 0.1ms | Allocations: 2645)


# 認可エンドポイントへリダイレクト後に処理を行い、認証レスポンスを返す
Started GET "/oauth/authorize?
client_id=***
&scope=openid
&response_type=code
&redirect_uri=http%3A%2F%2Flocalhost%3A3783%2Fcallback
&nonce=Hv6NwZUbfeurTX_si_KVQqiesCEZbBuU8B-nHmaeVhI&state=eyJyZXR1cm5UbyI6Ii8ifQ
&code_challenge_method=S256
&code_challenge=Fhixi79s1chqcCcuJBgj2_f-GwsX_lEED6vsft0Rsis" for 127.0.0.1 at 2021-09-07 00:29:44 +0900
...
Redirected to http://localhost:3783/callback?code=nCT2UBJFKRs4xDUvuqwy_PQ4fWydsJv6DQjoV4TOZGM&state=eyJyZXR1cm5UbyI6Ii8ifQ
Completed 302 Found in 30ms (ActiveRecord: 3.8ms | Allocations: 21095)


# トークンエンドポイントへのリクエストとトークンレスポンス
Started POST "/oauth/token" for 127.0.0.1 at 2021-09-07 00:29:44 +0900
...
  Parameters: 
{
  "grant_type"=>"authorization_code",
  "code"=>"[FILTERED]",
  "redirect_uri"=>"http://localhost:3783/callback",
  "code_verifier"=>"rIUSSmrrfjpDzwwg9QO_8x2y_wRRAZbGSsvMhcQ5Hhw"
}
...
Completed 200 OK in 40ms (Views: 0.2ms | ActiveRecord: 3.6ms | Allocations: 22240)


# IDトークンの署名検証のために、OPの公開鍵を取得
Started GET "/oauth/discovery/keys" for 127.0.0.1 at 2021-09-07 00:29:44 +0900
...
Completed 200 OK in 1ms (Views: 0.2ms | ActiveRecord: 0.0ms | Allocations: 356)


# UserInfoエンドポイントへアクセス
Started GET "/oauth/userinfo" for 127.0.0.1 at 2021-09-07 00:29:47 +0900
...
Completed 200 OK in 6ms (Views: 3.6ms | ActiveRecord: 0.3ms | Allocations: 4820)

 

未ログインで /profile にアクセスする場合

/ と同じような遷移をしていました。

 

ソースコード

Githubに上げました。
https://github.com/thinkAmi-sandbox/oidc_op_rp-sample

また、今回分のPRはこちらです。
https://github.com/thinkAmi-sandbox/oidc_op_rp-sample/pull/5