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