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