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

doorkeeper製 OAuth 2.0 のAuthorization Serverにて、色々な状態のアクセストークンを使って Introspection エンドポイントの挙動を確認してみた

2021/08/26 00:42 追記

ご指摘いただいた内容に合わせ、記事を修正しました。

主な修正点は以下の通りです。

  • タイトルをOAuth2.0な文脈へ変更
  • 環境に Resource Server を追加し、Resource Serverの中で Authorization Server の Introspection エンドポイントを呼び出すよう修正
    • これで、RS -> AS な挙動となったはず
    • それに合わせ、Client の中で行っていた Introspection エンドポイントの呼び出しを削除
  • OpenID Connect 向けの表記を OAuth 向けの表記に改めた
    • IntrospectionエンドポイントやRevokeエンドポイントは、 OAuth 2.0 のエンドポイントであるため
    • それに合わせ、 UserInfo エンドポイントでの挙動確認を取りやめ
    • その代わり、Resource Server のAPIを呼び出して挙動を確認する
      • RSのAPIの中で、Introspection エンドポイントによる access_token 検証をしている

2021/08/26 00:42 追記ここまで

2021/08/23 22:20 追記

この記事を公開したところ、Twitterにてご指摘をいただきました。ありがとうございました。

 
ご指摘の通り、IntrospectionエンドポイントがRS->ASではなくClient->ASとなっているため、この記事は適切ではありません。

RS->ASな形に記事を修正する予定ですが、ご注意ください。

2021/08/23 22:20 追記ここまで


 
以前、doorkeeper-openid_connectなどを使って、OpenID Connect の OpenID Provider と Relying Party を作成し、OpenID Connectでログインできるところまで作ってみました。
Railsとdoorkeeper-openid_connectやOmniAuth を使って、OpenID Connectの OpenID Provider と Relying Party を作ってみた - メモ的な思考的な

この時はログイン状態をセッションCookieで管理していたため、IDトークンの検証後は各種トークンを捨てていました。

 
そんな中、doorkeeperのWikiにて

  • Introspection エンドポイント
    • RFC7662 - OAuth 2.0 Token Introspection
  • Revocation エンドポイント
    • RFC7009 - OAuth 2.0 Token Revocation

など、アクセストークンの検証や取り消し用の OAuth 2.0 のエンドポイントについて記載されているのを見ました。
API endpoint descriptions and examples · doorkeeper-gem/doorkeeper Wiki

 
ここで、doorkeeperのデフォルトのアクセストークン形式は識別子型です。前回作成した時のアクセストークンも D3XwqTZYZV7xo3q12CTEUabY5a_z_-jKMpnmWXG75BQ のような識別子型でした。

 
そのため、もし今後

  • APIサーバなどの Resource Server を今の構成に追加
  • APIサーバの認可は、OAuth2.0 のアクセストークンで行う

を試す時

がある場合は、 Introspection・Revocationエンドポイントへのリクエストが発生しそうでした。

 
そこで今回、アクセストークンを

  • 渡された値そのまま
  • 不正なアクセストークンに差し替え
  • 有効期限切れ
  • Revocationエンドポイントで取り消し済

の各状態にして、Introspection・UserInfoエンドポイントの挙動を確認してみました。

 
目次

 

用語について

OAuth と OpenID Connect ではロール関連用語が異なります。

Auth屋さんの書籍「【電子版】OAuth、OAuth認証、OpenID Connectの違いを整理して理解できる本 - Auth屋 - BOOTH」の p36 から用語を引用すると、このような表になります。

No OAuthの場合 OpenID Connectの場合
1 Client Relying Party(RP)
2 Authorization Server (AS) OpenID Provider (OP)
3 Resource Server (RS) UserInfo エンドポイント

 
今回扱う

  • Revocationエンドポイント
  • Introspectionエンドポイント

は、両方ともOAuth関連のRFCとして定義されています。

そこで、今回の記事では OAuth のロール名で記載します。以前の記事とは異なりますので、ご注意ください。

ただし、ソースコードは以前の記事の続きであるため、ソースコード中の表記は OpenID Connect のロールになっています。

 

環境

  • macOS
  • Authorization Server (AS)
    • localhost:3780 で起動
    • Rails 6.1.4
    • doorkeeper 5.5.2
    • doorkeeper-openid_connect 1.8.0
    • devise 4.8.0
    • アクセストークンは doorkeeper のデフォルト形式
  • Client
    • localhost:3781 で起動
    • この Client から AS の Revocation エンドポイントへリクエストを出して、アクセストークンを取り消す
    • 認可コードフローで AS からアクセストークンをもらう
    • Rails 6.1.4
    • omniauth 2.0.4
    • omniauth-oauth2 1.7.1
  • Resource Server (RS)
    • localhost:3782 で起動
    • この RS から AS の Introspection エンドポイントへリクエストを出して、アクセストークンを検証する
    • Introspection用エンドポイントにアクセスするため、クライアントクレデンシャルフローで AS からアクセストークンをもらう
    • Rails 6.1.4
    • oauth2 1.4.7
      • クライアントクレデンシャルフローでASから access_token をもらう時に使用
    • faraday 1.7.0
      • ASの Introspection エンドポイントへアクセスする時に使用
  • アクセストークンの状態
    • ASから渡された値そのまま
    • 不正なアクセストークンに差し替え
    • 有効期限切れ
    • Revocationエンドポイントで取り消し済

 
なお、今回扱うソースコードは、この前の記事をベースにしたものです。
https://github.com/thinkAmi-sandbox/oidc_op_rp-sample

 

Authorization Server (AS) の設定変更

doorkeeperの allow_token_introspection を修正

今回、RSからASの Introspection エンドポイントへリクエストするため、 Introspection エンドポイントの利用を許可する設定を変更します。

設定ファイルでのコメントには

elsif token.application
  # `protected_resource` is a new database boolean column, for example
  authorized_client == token.application || authorized_client.protected_resource?

とあります。

ただ、 protected_resource というDB列は存在しないことから、 authorized_client == token.application だけを条件にします。

allow_token_introspection の全体はこんな感じになります。

allow_token_introspection do |token, authorized_client, authorized_token|
  if authorized_token
    authorized_token.application == token&.application ||
      authorized_token.scopes.include?("introspection")
  elsif token.application
    authorized_client == token.application  # 変更
  else
    true
  end
end

 

スコープを追加

OpenID Connectの場合はスコープとして openid が必要でした。

今回は Introspection も行うため optional_scopes に追加します。

Doorkeeper.configure do
# ...
  default_scopes :openid
  optional_scopes :introspection  # 追加
# ...

 

アクセストークンの有効期限を変更

今回アクセストークンの有効期限が切れたときの挙動も確認したいため、有効期限を短くして確認を容易にしてみます。

今回は1分で切れるよう、 config/initializers/doorkeeper.rbaccess_token_expires_in を修正します。

Doorkeeper.configure do
  # ...
  access_token_expires_in 1.minute
  # ..
end

 

Client の作成

前回とは別のRailsアプリを新規作成して Client としてもよいのですが、OmniAuthのストラテジーは一部修正すればそのまま使えそうでした。

また、以下の記事にあるように、OmniAuthでは1つのストラテジーを複数回使えそうでした。
同じストラテジーを複数回登録したい | OmniAuth OAuth2 を使って OAuth2 のストラテジーを作るときに知っていると幸せになれるかもしれないこと - Qiita

 
そこで、実用的ではないかもしれませんが、OmniAuthの動作確認もかねて、

  • 前回使ったClientのRailsアプリを流用
    • ストラテジーなどは必要な箇所だけ修正
  • 前回作成したOAuth Application はそのまま残す
    • ASで、別のOAuth Application として作成

の方針とします。

 

OmniAuthまわりの修正

.envファイルへの追記

別のOAuth Application として登録するため、 .env ファイルに

CLIENT_ID_OF_INTROSPECTION=
CLIENT_SECRET_OF_INTROSPECTION=

を追加します。

クライアントIDやシークレットの値は、後で AS から取得したものを設定します。

 

OmniAuthのストラテジーを修正

前回のストラテジーをほとんどそのまま流用できますが、IDトークンの検証で使う aud の値のクライアントIDが固定になっています。

そこで、ストラテジーを使った時のクライアントIDで検証できるよう、 .env ではなく options['client_id'] から取得するよう変更します。

修正対象のストラテジーのファイルは lib/omniauth/strategies/my_op.rb です。

def id_token_payload(id_token, subject_from_userinfo)
  payload, _header = JWT.decode(
    # ...
    { # options
      # ...
      # ストラテジーを使い回すため、client_idはoptionsから取得する
      aud: options['client_id'],
      # aud: ENV['CLIENT_ID_OF_MY_OP'],
      # ...

 

config/initializer/omniauth.rb の修正

同じストラテジーを使うため、同じストラテジーを複数回使えるよう nameintrospection を渡します。

また、scope introspection も追加します。

Rails.application.config.middleware.use OmniAuth::Builder do
  # 前回のストラテジー
  provider :my_op,
           ENV['CLIENT_ID_OF_MY_OP'],
           ENV['CLIENT_SECRET_OF_MY_OP']

  # 今回追加したストラテジー
  provider :my_op,
           ENV['CLIENT_ID_OF_INTROSPECTION'],
           ENV['CLIENT_SECRET_OF_INTROSPECTION'],
           name: 'introspection',
           scope: 'openid introspection'
end

 
ここまででOmniAuthまわりの修正が終わりました。

 

新しいOAuth Application 用の実装を追加

前回作成した sessions_controller とは別のControllerを作成して、今回のOAuth Applicationとします。

 

Controllerを生成

% bin/rails g controller Introspections index 
Running via Spring preloader in process 55835
      create  app/controllers/introspections_controller.rb
       route  get 'introspections/index'
      invoke  erb
      create    app/views/introspections
      create    app/views/introspections/index.html.erb
      invoke  helper
      create    app/helpers/introspections_helper.rb
      invoke  assets
      invoke    css
      create      app/assets/stylesheets/introspections.css

 

Viewの修正

ASへリクエストを飛ばせるよう、Viewを修正します。

なお、先ほど作成した OmniAuthのProvider introspection で処理できるよう、 form_tag のURLには /auth/introspection と指定します。

<h1>Introspections#index</h1>

<% if flash[:notice] %>
  <p>
    <%= flash[:notice] %>
  </p>
<% end %>

<%= form_tag('/auth/introspection', method: 'post') do %>
  <button type='submit'>Login</button>
<% end %>

 

routes.rb の修正

前回のroutes.rb ではAS からのコールバックURI

get 'auth/:provider/callback', to: 'sessions#create'

となっています。

このままではすべてSessions Controllerの create メソッドで処理されてしまいます。

そこで今回追加するOAuthApplicationの introspection は優先して処理されるよう、前回の定義よりも上に追加します。

 
なお、今回追加するものは

  • index
    • Viewを表示するための
  • callback
    • ASからのコールバックURI

の2つです。

Rails.application.routes.draw do
  # ルート設定
  root to: 'home#index'

  # 下のOPからのコールバックURIにマッチする前にマッチしたいのでここで設定
  # introspection_rp のコールバック先
  get 'auth/introspection/callback', to: 'introspections#callback'
  get 'introspection', to: 'introspections#index'

  # OPからのコールバックURI
  get 'auth/:provider/callback', to: 'sessions#create'
  # ...
end

 

Controllerの修正

今回のControllerでは、アクセストークンの状態

  • ASから渡された値そのまま
  • 不正なアクセストークンに差し替え
  • 有効期限切れ
  • Revocationエンドポイントで取り消し済

ごとに、RS のAPIへリクエストしレスポンスを受け取ります。

 
そこで、

  • RSのAPIへGETする
    • このAPIの中で、RSからASの Introspection エンドポイントへのリクエストが発生する
  • ASのRevocationエンドポイントへPOSTする

の2つのメソッドを用意します。

また、今回はいずれもFaradayを使ってみます。
Usage | Faraday

 

RSのAPIへGETするメソッドを追加

今回、RSのAPIにアクセスするにはASから渡された access_token が必要だとします。

Auth屋さんの本「【電子版】雰囲気でOAuth2.0を使っているエンジニアがOAuth2.0を整理して、手を動かしながら学べる本 - Auth屋 - BOOTH」より、Authorization ヘッダに Bearer + access_token をセットすることで、RSのAPIaccess_tokenを渡せます。

def fetch_resource_server(access_token)
  headers = { Authorization: "Bearer #{access_token}" }
  response = Faraday.get('http://localhost:3782/apples/show', {}, headers)
  response.tap do |r|
    puts '======> API'
    puts "STATUS: #{r.status}"
    puts "BODY  : #{r.body}"
    puts '<====== API'
  end
end

 

ASのRevocationエンドポイントへPOSTするメソッドを追加

アクセストークンやリフレッシュトークンを取り消すために、ASのRevocationエンドポイントへPOSTするメソッドを追加します。

doorkeeperのWikiによると

  • クライアントID
  • クライアントシークレット
  • アクセストーク

をPOSTすることで、アクセストークンやリフレッシュトークンを取り消せます。
POST /oauth/revoke | API endpoint descriptions and examples · doorkeeper-gem/doorkeeper Wiki

def revoke_tokens(access_token)
  params = {
    client_id: ENV['CLIENT_ID_OF_INTROSPECTION'],
    client_secret: ENV['CLIENT_SECRET_OF_INTROSPECTION'],
    token: access_token
  }
  response = Faraday.post("#{ENV['OIDC_PROVIDER_HOST']}/oauth/revoke", params)
  response.tap do |r|
    puts '======> introspection'
    puts r.headers
    puts '---------------------'
    puts r.body
    puts '<====== introspection'
  end
end

 

コールバックメソッドの作成

各エンドポイントへリクエストするメソッドができました。

最後に、前述の

  • ASから渡された値そのまま
  • 不正なアクセストークンに差し替え
  • 有効期限切れ
  • Revocationエンドポイントで取り消し済

のアクセストークンの状態ごとに動作を確認できるよう、ASからのコールバック用メソッドを作成します。

なお、動作確認するときには

  • 有効期限切れ
  • Revocationエンドポイントで取り消し済

を両立させるのは難しいため、コメントアウトするなどして片方ずつ確認します。

class IntrospectionsController < ApplicationController
  def index
  end

def callback
  auth_hash = request.env['omniauth.auth']
  access_token = auth_hash['credentials']['token']

  puts '================> CORRECT access_token'
  # Authorization Serverから受け取った access_token を使って、Resource Serverへリクエスト
  fetch_resource_server(access_token)

  # 不正な access_token を使って、Resource Serverへリクエスト
  puts '================> INCORRECT access_token'
  incorrect_access_token = "#{access_token}_bad"
  fetch_resource_server(incorrect_access_token)

  # 有効期限切れの access_token を使って、Resource Serverへリクエスト
  puts '================> EXPIRED access_token'
  sleep 70
  fetch_resource_server(access_token)

  # Clientで access_token を revoke 後に、Resource Serverへリクエスト
  puts '================> REVOKE access_token'
  revoke_tokens(access_token)
  fetch_resource_server(access_token)

  redirect_to introspection_path, notice: access_token
end
# ...

 

Resource Server (RS) の追加

Resource Server を

  • Clientからリクエストを受け付けるAPIを作成
  • APIの実行前に、ASのIntrospection APIaccess_token の検証を行う
    • 成功した場合、APIの結果を返す
    • 失敗した場合、HTTP 401

の仕様で実装します。

 

rails new

今回はAPIとして作成します。

% bundle exec rails new rails_resource_server --api

 

.env ファイルを追加

AS の情報を持つ .env ファイルを用意します。

OIDC_PROVIDER_HOST=http://localhost:3780
CLIENT_ID_OF_RESOURCE_SERVER=
CLIENT_SECRET_OF_RESOURCE_SERVER=

 

コントローラを作成

生成します。

% bin/rails g controller apples show
Running via Spring preloader in process 15084
      create  app/controllers/apples_controller.rb
       route  get 'apples/show'
      invoke  test_unit
      create    test/controllers/apples_controller_test.rb

 
コントローラでは

  • showメソッドで、APIへのリクエストに対しレスポンスする
  • showメソッドの before_action で、ASの Introspection API による access_token 検証を行う

を実装します。

後者については、以降で詳しくみていきます。

 

AS の Introspection API による access_token の検証

doorkeeperのWikiによると、Introspection APIを使うには

Post here with client credentials (in basic auth or in params client_id and client_secret) or with Bearer token to introspect an access/refresh token. This corresponds to the token endpoint, using the RFC7662 - OAuth 2.0 Token Introspection.

POST /oauth/introspect | API endpoint descriptions and examples · doorkeeper-gem/doorkeeper Wiki

とありました。

また、以下の記事には Authorization ヘッダにて Basic認証やBearerトークンを使っている例が記載されています。
RFC7662として発行されたOAuth Token Introspectionとは - r-weblife

 
そこで今回は Bearer token を使うこととし、OAuth2.0 のクライアントクレデンシャルフローにより、ASから access_token を取得します。

また、

  • Authorization ヘッダに、クライアントクレデンシャルフローで取得した access_token を Bearer token として設定
  • リクエストボディに、検証対象の access_token を設定

を行い、ASのIntrospection APIを呼び出すようにします。

 
RSのコントローラの全体像はこんな感じです。

class ApplesController < ApplicationController
  before_action :validate_bearer_token
  def show
    render json: { name: 'シナノゴールド' }
  end

  private

  def validate_bearer_token
    # Bearer トークンを取得
    authorization_header = request.headers['Authorization']
    return render status: 401 if authorization_header.blank?

    access_token = authorization_header.gsub('Bearer ', '')
    return render status: 401 if access_token.blank?

    # クライアントクレデンシャルフローで、Resource Serverのアクセストークンを取得する
    client = OAuth2::Client.new(ENV['CLIENT_ID_OF_RESOURCE_SERVER'],
                                ENV['CLIENT_SECRET_OF_RESOURCE_SERVER'],
                                site: ENV['OIDC_PROVIDER_HOST'])
    oauth2_response = client.client_credentials.get_token(scope: 'introspection')

    # Faradayを使って、Introspectionエンドポイントで access_token を検証
    headers = { Authorization: "Bearer #{oauth2_response.token}" }
    params = { token: access_token }
    response = Faraday.post("#{ENV['OIDC_PROVIDER_HOST']}/oauth/introspect", params, headers)

    response.tap do |r|
      puts '======> introspection'
      puts "STATUS: #{r.status}"
      puts "BODY  : #{r.body}"
      puts '<====== introspection'
    end

    body = JSON.parse(response.body)
    render status: 401 if response.status == 401 || body['active'] == false
  end
end

 

pumaの起動ポートを修正

ASやClient同様、pumaの起動ポートを修正します。

config/puma.rb

port ENV.fetch("PORT") { 3782 }

 
以上でRSの実装も終わりました。

 

動作確認

OAuth Applicationを準備

http://localhost:3780/oauth/applications にアクセスし、OAuthApplicationを登録します。

今回はClientとRSの2つを追加します。

項目 Client用 RS用
Name introspection_rp resource_server
Callback URL http://localhost:3781/auth/introspection/callback http://localhost:3782
(本来なら不要だが、空欄だとエラーになるため)
Confidential? Yes Yes
Scopes openid introspection introspection

 
登録後に表示されるクライアントID・クライアントシークレットは、ClientやRSの .env ファイルの

  • CLIENT_ID_OF_INTROSPECTION
  • CLIENT_SECRET_OF_INTROSPECTION

などの値へ設定します。

 

実行

今回は

  1. http://localhost:3781/introspection にアクセスし、 Login ボタンをクリック
  2. ASでログイン
  3. ASの認可画面で許可
  4. Clientに戻ってきて、access_token が表示

の順による動作確認をします。

なお、再掲となりますが、

  • 有効期限切れ
  • revoke済

は両立しないため、コメントアウトするなどして片方ずつ試します。

 

ASから渡された値そのままのアクセストークンの場合

ClientのログにはRSのAPI呼び出しが成功し、APIから値が返ってきていることが分かりました。

================> CORRECT access_token
======> API
STATUS: 200
BODY  : {"name":"シナノゴールド"}
<====== API

 
また、RSのログを見ると、 access_token の Introspection が成功していました。

Started GET "/apples/show" for 127.0.0.1 at 2021-08-25 22:57:53 +0900
Processing by ApplesController#show as */*
======> introspection
STATUS: 200
BODY  : {"active":true,"scope":"openid introspection","client_id":"CLIENT_ID","token_type":"Bearer","exp":1629899933,"iat":1629899873}
<====== introspection

 

不正なアクセストークンに差し替えた場合

ClientのログにはRSのAPI呼び出しが失敗し、HTTP 401 が返ってきていることが分かりました。

================> INCORRECT access_token
======> API
STATUS: 401
BODY  :  
<====== API

 
また、RSのログを見ると、Introspectionエンドポイントから "active":false が返っていました。

======> introspection
STATUS: 200
BODY  : {"active":false}
<====== introspection

 

有効期限切れのアクセストークンの場合

ClientのログにはRSのAPI呼び出しが失敗し、HTTP 401 が返ってきていることが分かりました。

================> EXPIRED access_token
======> API
STATUS: 401
BODY  :  
<====== API

 
また、RSのログを見ると、Introspectionエンドポイントから "active":false が返っていました。

======> introspection
STATUS: 200
BODY  : {"active":false}
<====== introspection

 

Revocationエンドポイントで取り消し済のアクセストークンの場合

Clientのログより、

  • token Revocationエンドポイントから、HTTP 200が返る
  • RSのAPI呼び出しが失敗

と分かりました。

================> REVOKE access_token
======> revocation
STATUS: 200
BODY  : {}
<====== revocation
======> API
STATUS: 401
BODY  :  
<====== API

 
また、RSのログを見ると、Introspectionエンドポイントから "active":false が返っていました。

======> introspection
STATUS: 200
BODY  : {"active":false}
<====== introspection

 

ソースコード

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

 
今回の実装分ですが、最初に公開した時のPRはこちらです。
https://github.com/thinkAmi-sandbox/oidc_op_rp-sample/pull/2

また、ご指摘を受けて修正した時のPRはこちらです。
https://github.com/thinkAmi-sandbox/oidc_op_rp-sample/pull/3

ヒドいぎっくり腰になったので、経過を記録してみた

最近、ヒドいぎっくり腰(急性腰痛症)になりました。

 
過去に何回かぎっくり腰をやった時は、しばらく安静にしていることで、いずれもその日のうちに動けるようになっていました。

それに比べて今回のはヒドく、人生初の救急車や入院を経験し、各方面にご迷惑をおかけしました。

そこで、

  • もうぎっくり腰になりたくないので、筋トレやストレッチをがんばりたい
    • 忘れた頃にこのエントリーを読み返して、モチベーションを維持する
  • 万が一、今後も発生したときに「どれくらいで治るか」の目安にする

を目的とした、症状の経過に関するメモを残します。

 
目次

 

初日

  • 朝シャワーを浴びた後、ふと気づいたら腰に力が入らなくなり、パンイチで倒れ込んだ
    • 発症した時の記憶があいまい。。。
    • 動こうとすると、腰に電撃が走るような痛みが出る
  • 30分くらいたっても変わらないため、寝室へ移動
    • 腰が立たず二足歩行できないことから、移動方法はハイハイ
    • 道中、冷蔵庫から保冷剤を取り出し、服でくるんで腰に乗せた
    • 換気のために部屋の窓を開けていたが、腰が立たないため、窓を閉めたりエアコンを入れたりできず
  • こんなこともあろうかと、手の届く高さに保管しておいたコルセットを枕元に置いた
  • 会社のデイリースクラムの時間になっても動けないことから、Slackで休暇連絡
    • この時点では「ぎっくり腰だろうけど、時間がたてば動けるようになるから整形外科へ行こう」と思っていた
  • 足の感覚はあり自由に動かせたが、腰だけが言うことをきかなかった
    • いつもどうやって腰を動かしていたのか思い出せず
  • うつ伏せや仰向けだと痛みが出るので、横向きで安静にしてたら、いつの間にか寝てた
  • 起きたらお昼の時間を過ぎていたが、食欲がなかったので、お昼ごはんはパスした
  • 時間がたつにつれ、痛みの出ない姿勢が減っていったため、姿勢を求めて寝返りを繰り返した
  • 西窓の部屋だったため、午後日差しが強くなり気温も上がってきた
    • 「このままでは直射日光を食らう & 熱中症でやられるのでは」と思ったが動けず
    • 結局曇りになったので直射日光は避けられたものの、水分補給ができないままなので熱中症の不安は残った
  • 整形外科の診察締め切り時間が近づいたので、コルセットをつけた後に起きようとしたが、激しい痛みで起き上がれず
    • コルセットをつけたほうが痛みが強かった
  • そのうち、こむら返りの腰版が発生するようになった
    • 発生すると、腰から全身へ痛みが広がる
    • 少しでも動くとこむら返りの腰版が発生し、大声を出しながら転げ回ってた
  • 時間の経過とともにこむら返りの腰版が頻発するようになり、「さすがにやばいのでは」と思い始めた
  • 晩ごはんの食欲もわかず
  • 夜になっても腰が悪化し続けたので、覚悟を決めた
    • 会社にはSlackで「翌日も休みます」連絡
    • 翌日の私用もキャンセル
  • 必要最低限の荷物を用意して通報
    • 会社スマホ & 自分スマホ & モバイルバッテリー & 保険証 & 財布 & 1日分の着替え
    • 保冷剤をくるんでた服が何とか回収・着用できたため、パンイチでの救急車は避けられた
  • ありがたいことに、救急車はすぐ到着
    • 到着しても、こむら返りの腰版で転げ回る
  • 担架で搬送
  • 救急で受け入れてもらえることになり、ホッとした
  • 救急車の中を色々見ることができるくらいには、心の余裕が出てきていた
  • 病院に到着直後、検査するには痛みが強すぎたらしく、坐薬が投入された
    • あとで聞いたところ、一番強いものが投入されたらしい
  • しばらく安静にしていても状態があまり変わらず
    • 坐薬が効いてる時間になったけれど、姿勢によっては腰に電撃が走る
      • こむら返りの腰版が発生しないくらい
    • ただ、検査しないと何も始まらないので、ゴロゴロ回転してストレッチャーに移動してCT検査へ
    • 初めてのCTにはワクワクした
  • CTの結果、骨はキレイだったらしく、ぎっくり腰と診断された
  • とはいえ動けないので、入院することに
    • 帰宅しても生活できないので、心の底から感謝した
  • ストレッチャーで病室へ移動
    • 完全に坐薬が効いている時間だが、まだ姿勢によっては腰に電撃が走る状態だった
    • 複数の看護師さんの力をお借りして、シーツごとベッドへ移動
  • 坐薬で眠れるだろうと思ったが、意外と寝付けず
    • 寝ても痛みで起きた

 

2日目

  • 朝起きても、腰が立たない、腰が動かない
    • 上半身と下半身は問題なく動くので、それをつなげる部分だけが動かないという不思議な感覚
    • この腰が本当に治るのか、そして退院できるのかと不安になる
  • 朝ごはんが届く
    • 食欲は戻っていた
    • ベッドを起こした時に腕が届く範囲にごはんを配膳してもらい、ベッドに体を支えてもらいながら腕とフォークだけを使って食べた
    • 24時間ぶりに水分補給できた
  • 退院の見込みがたたないので、会社にその旨を連絡
  • 会社への連絡後、薬がきいたのか、お昼までよく寝た
  • お昼ごはんも、ベッドを起こし、腕とフォークだけで何とか食べた
    • 食後に坐薬を入れ、また寝た
  • 夕方、さすがにトイレに行きたくなり、手押しの点滴台とクロックスを借りて、手すりを伝って移動することに
    • 担架で運ばれた救急で靴を持ってきていなかったので、クロックスを借りられて本当にありがたかった
    • 腰は立たないままだが、腕と手すりを頼りに何とかトイレに行け、ホッとした
    • また、「今後この状態と比較することで、回復具合を把握できそう」と思った
  • 晩ごはんの時に、横向きでの上体起こしを試みたが、痛みが出たため失敗に終わった
    • 朝昼同様、ベッドを起こして腕とフォークだけで食べた
  • 晩ごはんから消灯時間までの間に、スマホブラウジングしたところ姿勢が固定されたようで、腰に痛みが出た
    • なお、スマホのバッテリーにも余裕がなく、その後ブラウジングする機会はなかった
      • 持ち物として充電器を忘れるという痛恨のミス
      • 売店でも売ってないとのこと
  • 結局ゴロゴロ回転して時間を過ごし、消灯時間になったら眠った
  • 夜中、腰に痛みが出るたびに目覚めた
    • 腰を動かせられないため寝返りが打てず、ずっと同じ姿勢だったのが原因っぽい

 

3日目

  • 朝、腰の重い感じが抜けていない
  • 朝ごはんを食べる時に、横向きでの上体起こしを試したところ、だいぶ上がるようになった
    • 手すりにつかまることで、ベッドを起こさなくても食べられるくらいになった
    • 「腰が言うことをきいてくれる」と嬉しくなった
  • 点滴台と一緒であれば、よちよち歩きでトイレに行けた
    • とはいえ、歩行時に腰が強くしめつけられているような痛みや圧迫感はあった
    • また、手すりがないと、ベッドから降りるのも難しい状態
  • この日も同じ姿勢でいると腰が固まるのか、痛みが発生してた
    • ベッドの上でゴロゴロしながら過ごす
  • まだ自宅で生活できそうにないので、明日退院を目標にした
  • 夜眠れないと困るので、坐薬を入れてもらって就寝
    • それでも、長い時間姿勢が固定されたときは目覚めた

 

4日目

  • 朝起きたら、だいぶ体が軽い
  • 横向きでの上体起こしができるようになり、ベッドに腰掛けられるようになった
    • 朝ごはんから、ベッドに腰掛けて食べることができた
  • かがむと痛みが出る
    • 前傾・後傾も無理
    • まっすぐ立つ分には問題ない
  • 点滴台なしでも、よちよち歩きができるようになった
    • 腰に圧迫感はあったが、昨日よりは確実に改善していた
  • この状態であれば、家でも生活できそうと判断
    • スマホのバッテリーがギリギリだった、ということもあり
  • 退院
    • お世話になりました、ありがとうございました
  • 久しぶりの外の世界
    • 病院内には無かった段差があったものの、何とか乗り越えた
  • 無事に帰宅
    • 病院内に比べて長い距離を歩いたせいか、股関節と尻が筋肉痛っぽくなった
    • 久しぶりにシャワーを浴びた
  • 湿布を貼ったり薬を飲んだり
  • 基本は横になってた

 

5日目

  • 起床直後が一番腰に来ている
    • 日中動くとだいぶ良くなる
  • 上体起こしまでは問題なくできるようになった
    • 机につかまり、立ち上がることもできた
    • ただし、痛みは出る
  • 腰に電撃が走るような痛みは消えた
  • よちよち歩き
  • 湿布を貼ったり薬を飲んだり
  • 基本は横になってた

 

6日目

  • 起床直後が一番腰に来ている
    • 日中動くとだいぶ良くなる
  • 机を使ったつかまり立ちもスムーズになった
    • 腰の違和感はある
  • 歩く歩幅が広がった
  • 腰に変な力が入ると、筋肉の張るような痛みが出る
  • 湿布を貼ったり薬を飲んだり

 

7日目

  • 起床直後が一番腰に来ている
    • 日中動くとだいぶ良くなる
  • 立ち上がりもスムーズに
  • 歩き方もスムーズに
  • 斜め前のものを取ろうとすると、腰が張るような痛みが出る
  • 湿布を貼ったり薬を飲んだり

 

8日目〜

ここからは緩やかな回復だったため、まとめて書きます。

もし、何か気づいたことがあれば、このメモに追記します。

  • 日を追うごとに回復
  • 起床直後が一番腰に来ている
    • 日中動くとだいぶ良くなる
  • 電撃が走るような痛みに代わり、腰への圧迫を感じる範囲が広がった
    • 湿布を増やして対処すればおさまる
  • かがむと、腰の張りを感じる
  • 寝起きは問題なくできる
  • 歩行はスムーズにできる
  • ジャンプやダッシュはこわい
  • くしゃみするときも、腰に反動を感じる
  • 食洗機や洗濯機、料理くらいはなんとかなる
    • シンクに置いた洗い物を取ろうとすると腰に張りが出るので、食洗機さまさま
  • 重いものを持とうとすると腰に不安が出る
    • 物の正面に立ち、腰を落として持てば、何とか持てる
    • とはいえ、重い物を持つことへの恐怖感はまだある
      • 気持ちとしては、鈍器な技術書が限界
  • スタンディングデスクメインであれば、macでタイピングするのは問題ない
    • 疲れたら、座ったり歩き回る
  • 腹筋は問題なくできる
    • 背筋のストレッチをやろうとすると、腰に不安が出る
  • 薬が終わり、湿布だけになった
    • 痛みが出たら市販薬

 
以降は、気づいた時に追記している内容です。

 

14日目

  • 自転車へ乗れるようになった
    • ギアは一番軽いけど
  • 足の甲や足首のむくみがひどい
    • 血管や骨が見えないほど

 

15日目

  • 30分くらい、ゆっくりしたペースで連続して歩けるようになった

 

16日目

  • おしりや股関節が筋肉痛のような痛みが出た
    • 最近運動不足だったので、歩いた影響かもしれない
  • 腰の不安なく、階段の上り下りができるようになった
  • 前屈するとまだ痛い

 

20日

  • 一日座って仕事しても大丈夫になった
  • 座って仕事する時間が増えたせいか、足のむくみは取れた
  • 浅い前屈であれば痛みは出なくなった
  • 立ってズボンをはけるようになった
  • バックパックに重い物を背負った時、腰に不安感はある

 

27日目

  • 片道40分くらい自動車を運転したら、翌日腰に違和感が出た

 

終わりに

現在変な姿勢だと腰に違和感が出るものの、普通に生活できるようになりました。初日の状態から考えると、本当に奇跡としか言いようがないです。

 
最後になりましたが、公私とも色々な方々にご迷惑をおかけしました。ありがとうございました。

今後ぎっくり腰にならないよう、日々体のメンテナンスを続けていこうと思います。