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分くらい自動車を運転したら、翌日腰に違和感が出た

 

終わりに

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

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

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

Railsとdoorkeeper-openid_connectやOmniAuth を使って、OpenID Connectの OpenID Provider と Relying Party を作ってみた

OAuth2やOpenID Connectの理解を深めようと思い、

などを読みました。

読んでいくうちに、OpenID Connectの OpenID Provider (以降、OP) と Relying Party(以降、RP)の両方を実装し、動作を確認してみたくなりました。

そこで、最近使ってるRuby + Railsにて、OPとRPを実装してみたときのメモを残します。

なお、今回は動作を確認するのが主な目的なため、OPやRPをイチから作るのではなく、gemを組み合わせて作ることとします。

 
目次

 

環境

gemなどのバージョン

  • 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:3781 で起動
    • Rails 6.1.4
    • omniauth 2.0.4
    • omniauth-oauth2 1.7.1
    • activerecord-session_store 2.0.0
      • セッションストアをActive Record (DB) へと変更

 

実装すること

  • OpenID ConnectのOPとRPをRuby + gem で実装
  • OpenID Connectのフローは 認可コードフロー のみ実装
  • IDトークンの検証で使うOPの公開鍵は、OPの公開鍵エンドポイントから動的に取得する
  • セキュリティ対策
    • statePKCEnonce
      • 上記は、使用するgemで実装済かどうかも調べる

 
また、OpenID Connect フローの概要を OpenID Connect Core 1.0 より引用します。

+--------+                                   +--------+
|        |                                   |        |
|        |---------(1) AuthN Request-------->|        |
|        |                                   |        |
|        |  +--------+                       |        |
|        |  |        |                       |        |
|        |  |  End-  |<--(2) AuthN & AuthZ-->|        |
|        |  |  User  |                       |        |
|   RP   |  |        |                       |   OP   |
|        |  +--------+                       |        |
|        |                                   |        |
|        |<--------(3) AuthN Response--------|        |
|        |                                   |        |
|        |---------(4) UserInfo Request----->|        |
|        |                                   |        |
|        |<--------(5) UserInfo Response-----|        |
|        |                                   |        |
+--------+                                   +--------+

 

実装しないこと

 

Open ID Provider (OP)の作成

まずは、Open ID Provider から作成します。

 

OP実装で使用するgemについて

以下のスライドより、Rails + doorkeeper + doorkeeper-openid_connect で作ります。
わかった気になる!OpenID Connect - Speaker Deck

また、ユーザー登録や認証を容易にするため、devise も使います。

 

各種インストール

rails new します。

% bundle exec rails new rails_open_id_provider --skip-javascript --skip-turbolinks --skip-sprockets --skip-test

 
Gemfileに

を追加します。

# 追加
gem 'devise'
gem 'doorkeeper'
gem 'doorkeeper-openid_connect'

group :development do
  gem 'annotate'
end

 
bundle install します。

% bundle install
...
Post-install message from doorkeeper:
Starting from 5.5.0 RC1 Doorkeeper requires client authentication for Resource Owner Password Grant
as stated in the OAuth RFC. You have to create a new OAuth client (Doorkeeper::Application) if you didn't
have it before and use client credentials in HTTP Basic auth if you previously used this grant flow without
client authentication.

To opt out of this you could set the "skip_client_authentication_for_password_grant" configuration option
to "true", but note that this is in violation of the OAuth spec and represents a security risk.

Read https://github.com/doorkeeper-gem/doorkeeper/issues/561#issuecomment-612857163 for more details.

 
READMEに従い、 annotate をセットアップします。
https://github.com/ctran/annotate_models#configuration-in-rails

% bin/rails g annotate:install
Running via Spring preloader in process 8056
      create  lib/tasks/auto_annotate_models.rake

 

起動ポートのデフォルト値を修正

後ほど RP の Railsアプリも作成するため、デフォルトのままでは起動ポート 3000 が重複してしまいます。

そこで、以下に従い config/puma.rb を修正します。
rails s 時のデフォルトのポート番号を変更する - Qiita

OPは 3780 ポートにしてみます。

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

 

deviseのセットアップ

installします。

% bin/rails g devise:install
Running via Spring preloader in process 7473
      create  config/initializers/devise.rb
      create  config/locales/devise.en.yml

 
OPで管理するUserのモデルを作成します。

% bin/rails g devise user   
Running via Spring preloader in process 7543
      invoke  active_record
      create    db/migrate/20210812140841_devise_create_users.rb
      create    app/models/user.rb
      insert    app/models/user.rb
       route  devise_for :users

 
今回使うdeviseの機能は

  • ユーザー登録
  • ログイン

だけになります。

そこで、マイグレーションファイル db/migrate/<TIMESTAMP>_devise_create_users.rb を修正し、database authenticatable だけ有効にします。

class DeviseCreateUsers < ActiveRecord::Migration[6.1]
  def change
    create_table :users do |t|
      ## Database authenticatable
      t.string :email,              null: false, default: ""
      t.string :encrypted_password, null: false, default: ""

      t.timestamps null: false
    end

    add_index :users, :email,                unique: true
  end
end

 
また、Userモデル (app/models/user.rb) のdeviseモジュールも修正しておきます。

class User < ApplicationRecord
  devise :database_authenticatable, :registerable, :validatable
end

 

doorkeeperのセットアップ

devise のセットアップが終わったため、次は doorkeeper のセットアップを行います。

 

初期化

ドキュメントに従いセットアップします。
https://doorkeeper.gitbook.io/guides/ruby-on-rails/getting-started

 
まずは install します。

% bin/rails generate doorkeeper:install
Running via Spring preloader in process 8136
      create  config/initializers/doorkeeper.rb
      create  config/locales/doorkeeper.en.yml
       route  use_doorkeeper
===============================================================================

There is a setup that you need to do before you can use doorkeeper.

Step 1.
Go to config/initializers/doorkeeper.rb and configure
resource_owner_authenticator block.

Step 2.
Choose the ORM:

If you want to use ActiveRecord run:

  rails generate doorkeeper:migration

And run

  rake db:migrate

Step 3.
That's it, that's all. Enjoy!

===============================================================================

 
続いて、マイグレーションファイルを生成します。

% bin/rails generate doorkeeper:migration
Running via Spring preloader in process 8229
      create  db/migrate/20210812141653_create_doorkeeper_tables.rb

 
devise の User モデルに合わせるため、マイグレーションファイルの以下の部分を修正します。

# Uncomment below to ensure a valid reference to the resource owner's table
add_foreign_key :oauth_access_grants, :users, column: :resource_owner_id
add_foreign_key :oauth_access_tokens, :users, column: :resource_owner_id

 

設定変更

doorkeeper の設定を行うため、 config/initializer/doorkeeper.rb ファイルを修正します。

今回は以下の設定を行います。

  • resource_owner_authenticator
  • admin_authenticator
    • 今回はユーザーが全てのOAuth Applicationの操作可能とするため、doorkeeperのドキュメントに従って設定
    • Configuration - doorkeeper
  • default_scopes
    • OpenID Connect を使うため、 openid

 
なお、 skip_authorization の設定で true を返すようにすれば、「同意画面を表示せず、自動で同意したことにする」ことも可能です。
https://github.com/doorkeeper-gem/doorkeeper/blob/v5.5.2/lib/generators/doorkeeper/templates/initializer.rb#L426

ただし、RPの認証リクエストのパラメータで prompt が指定された場合は、 prompt の値に従った動作となります。
21. プロンプト (prompt) | OAuth & OpenID Connect 関連仕様まとめ - Qiita

prompt パラメータはこのPRあたりで有効になったようです。
Support other prompt parameters by toupeira · Pull Request #20 · doorkeeper-gem/doorkeeper-openid_connect

 
他のdoorkeeperの設定については、ソースコードのコメントを読む他、Wikiやドキュメントなどに記載があります。

 
不要なコメントを削除した設定内容は以下の通りです。

# frozen_string_literal: true

Doorkeeper.configure do
  orm :active_record
  
  resource_owner_authenticator do
    current_user || warden.authenticate!(scope: :user)
  end

  admin_authenticator do
    current_user || warden.authenticate!(scope: :user)
  end

  default_scopes  :openid
end

 

doorkeeper-openid_connectのセットアップ

初期化

READMEに従い、セットアップします。
https://github.com/doorkeeper-gem/doorkeeper-openid_connect

 
installします。

% bin/rails g doorkeeper:openid_connect:install 
Running via Spring preloader in process 8849
      create  config/initializers/doorkeeper_openid_connect.rb
      create  config/locales/doorkeeper_openid_connect.en.yml
       route  use_doorkeeper_openid_connect

 
マイグレーションファイルを生成します。

% bin/rails generate doorkeeper:openid_connect:migration
Running via Spring preloader in process 8917
      create  db/migrate/20210812142144_create_doorkeeper_openid_connect_tables.rb

 

IDトークンの署名で使う鍵を生成

IDトークンの署名で使うための鍵を生成します。

今回は公開鍵方式にするため、以下を参考に RS256 の鍵を生成します。

# rails_open_id_provider ディレクトリの直下で実行
% ssh-keygen -t rsa -P "" -b 4096 -m PEM -f jwtRS256.key
Generating public/private rsa key pair.
Your identification has been saved in jwtRS256.key.
Your public key has been saved in jwtRS256.key.pub.

なお、誤ってcommitしないよう、生成した鍵は .gitignore に追加しておきます。

 

設定変更

doorkeeper-openid_connect の設定を行うため、 config/initializer/doorkeeper_openid_connect.rb ファイルを修正します。

今回は以下の設定を行います。

  • issuer
    • OPの稼働するホスト名 ( http://localhost:3780 )
  • signing_key
  • claims
    • UserInfoエンドポイントで返す claims に email を追加
  • 以下の項目はコメントアウトされているものをアンコメント
    • resource_owner_from_access_token
    • reauthenticate_resource_owner
    • subject

 
不要なコメントを削除した設定内容は以下の通りです。

# frozen_string_literal: true

Doorkeeper::OpenidConnect.configure do
  issuer 'http://localhost:3780'

  signing_key File.read(Rails.root.join('jwtRS256.key'))

  subject_types_supported [:public]

  resource_owner_from_access_token do |access_token|
    User.find_by(id: access_token.resource_owner_id)
  end

  auth_time_from_resource_owner do |resource_owner|
  end

  reauthenticate_resource_owner do |resource_owner, return_to|
    store_location_for resource_owner, return_to
    sign_out resource_owner
    redirect_to new_user_session_url
  end

  select_account_for_resource_owner do |resource_owner, return_to|
  end

  subject do |resource_owner, _application|
    resource_owner.id
  end

  claims do
    # normal_claimはclaimのalias
    normal_claim :email, scope: :openid do |resource_owner|
      resource_owner.email
    end
  end
end

 

routeの確認

doorkeeper-openid_connect により、OpenID Connect 向けのrouteが追加されています。

% bin/rails routes
Verb   URI Pattern                                  Controller#Action
GET    /oauth/userinfo(.:format)                    doorkeeper/openid_connect/userinfo#show
POST   /oauth/userinfo(.:format)                    doorkeeper/openid_connect/userinfo#show
GET    /oauth/discovery/keys(.:format)              doorkeeper/openid_connect/discovery#keys
GET    /.well-known/openid-configuration(.:format)  doorkeeper/openid_connect/discovery#provider
GET    /.well-known/webfinger(.:format)             doorkeeper/openid_connect/discovery#webfinger
...

 

マイグレーション

セキュリティまわりを除き一通りの実装が終わったため、マイグレーションを実行しておきます。

% bin/rake db:migrate
...
Annotated (1): app/models/user.rb

 

Relying Party (RP) の作成

続いて、Relying Party (以下 RP) を作成します。

 

実装方針

今回のRPの機能は

  • OPのユーザー情報を元にログイン
    • RPにユーザーがいない場合は、RPのユーザーを作成
  • ログアウト

だけです。

そのため、deviseを使わずに、ログイン/ログアウトを実装してみます。

なお、OpenID Connect部分については OmniAuth を使います。
omniauth/omniauth: OmniAuth is a flexible authentication system utilizing Rack middleware.

ちなみに、OmniAuthの2系を使うため、公式Wikiにて注意点などを確認します。
Upgrading to 2.0 · omniauth/omniauth Wiki

 
その他のgemとして、

  • デバッグ時にセッションの中身を見やすくするため、セッションをActive Recordに保存
    • activerecord-session_store
  • OPから払い出されたクライアントIDとクライアントシークレットをハードコーディングするのではなく、 .env ファイルから読み込む
    • dotenv-rails

も使います。

 

OmniAuthのストラテジーについて

OmniAuthのストラテジーの一覧は、公式のWikiに記載されています。
List of Strategies · omniauth/omniauth Wiki

しかし、OpenID Connectのストラテジーは一覧に見当たりませんでした。

 
調べてみたところ、以下のgemがありました。
https://github.com/m0n9oose/omniauth_openid_connect

ただ、OmniAuthが1.9系でした。2系に対応するPRはありましたが、取り込まれていませんでした。
https://github.com/m0n9oose/omniauth_openid_connect/pull/84

Gitlabでもこのgemを使ってるようでしたが、色々あってGitlab側でforkしたようです。
https://gitlab.com/gitlab-org/gitlab/-/issues/225850

fork先を見てみましたが、色々修正が入っているものの、OmniAuthは1.9系のままでした。
https://gitlab.com/gitlab-org/gitlab-omniauth-openid-connect

 
そこで、以下の記事やソースコードを参考にしながら、OAuth2のストラテジーOpenID Connectの機能を追加したものを自作します。

 

各種インストール

rails new します。

% bundle exec rails new rails_relying_party_of_backend --skip-javascript --skip-turbolinks --skip-sprockets --skip-test

 
Gemfileに以下を追加して bundle install します。

# 追加
# omniauthまわり
gem 'omniauth'
gem 'omniauth-oauth2'

# OmniAuthのWikiにある通り、CSRF対策として追加
gem 'omniauth-rails_csrf_protection'

# デバッグ時にセッションの中身を見やすくするよう、セッションを ActiveRecord で管理するために追加
gem 'activerecord-session_store'

# OPからもらった client_id と client_secret を .env ファイルから読み込むために追加
gem 'dotenv-rails', groups: [:development, :test]

# modelにスキーマコメントを追加
group :development do
  gem 'annotate'
end

 

Active Record によるセッションストアの設定

activerecord-session_store でセッションストアをActive Record にしたため、READMEに従い設定を追加します。
https://github.com/rails/activerecord-session_store

 
マイグレーションファイルを生成します。

% bin/rails generate active_record:session_migration
Running via Spring preloader in process 99042
      create  db/migrate/20210813010744_add_sessions_table.rb

 
config/initializer/session_store.rb を作成して設定を行います。

# ActiveRecordにセッションを保存
Rails.application.config.session_store :active_record_store, :key => '_my_app_session'

 
セッションストアの中身を分かりやすくするよう、JSON形式でデータを保存するようにします。

config/application.rb の末尾に以下を追加します。

# 中身を見やすくするよう、JSON形式で保存
ActiveRecord::SessionStore::Session.serializer = :json

 

起動ポートのデフォルト値を修正

OPと同じくRailsを使うため、RPは 3781 ポートで起動するよう config/puma.rb を修正します。

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

 

OmniAuthの設定

OmniAuthのREADMEにある Getting Started に従い config/initializers/omniauth.rb を作成します。
https://github.com/omniauth/omniauth#getting-started

providerには

  • 第1引数
    • あとで作成するストラテジーの名称を指定
  • 第2引数
    • OPより払い出された、クライアントID
      • 今回は .env ファイルに記載し、 dotenv-rails で読み込む
  • 第3引数
    • OPより払い出された、クライアントシークレット
      • こちらも今回は .env ファイルに記載し、 dotenv-rails で読み込む

を指定します。

なお、ストラテジーで設定されている内容を上書きしたい場合は、以下のAuth0のストラテジーのように、引数としてハッシュを渡せば良いようです。
https://github.com/auth0/omniauth-auth0#additional-authentication-parameters

require 'omniauth/strategies/my_op'

Rails.application.config.middleware.use OmniAuth::Builder do
  provider :my_op,
           ENV['CLIENT_ID_OF_MY_OP'],
           ENV['CLIENT_SECRET_OF_MY_OP']

  # ストラテジーの設定内容を上書きする場合は、以下のように追加しても良い
  # 参考: https://github.com/auth0/omniauth-auth0#additional-authentication-parameters
           # {
           #   authorize_params: {
           #     scope: 'openid profile',
           #     prompt: 'none'
           #   }
           # }
end

 

OmniAuthのストラテジーを作成

OmniAuthの設定で追加した my_op 用のストラテジーを作成します。

OmniAuth OAuth2のREADMEに従い、ファイル lib/omniauth/strategies/my_op.rbOmniAuth::Strategies::OAuth2 を継承したストラテジーを作成します。
https://github.com/omniauth/omniauth-oauth2

 

認証リクエストのパラメータを定義

まずは、認証リクエストのパラメータとして

  • name
    • ストラテジーの名前なので、 my_op
  • client_options
    • siteで、認証リクエストのエンドポイントを指定
  • scope
    • OpenID Connectのscopeなので、 openid

を設定します。

require 'omniauth-oauth2'

module OmniAuth
  module Strategies
    class MyOp < OmniAuth::Strategies::OAuth2
      # Give your strategy a name.
      option :name, 'my_op'

      # This is where you pass the options you would pass when
      # initializing your consumer from the OAuth gem.
      option :client_options, {
        site: "#{ENV['OIDC_PROVIDER_HOST']}/oauth/authorize"
      }

      # scope=openid としてリクエスト
      option :scope, 'openid'
    end
  end
end

 

トークンエンドポイントから取得したIDトークンの検証とペイロードの取得

続いて、トークンエンドポイントから取得したIDトークンを検証する処理を実装します。

IDトークンは署名付JWT (JWS) のため、

  1. デコードしてIDトークンの中身を取得
  2. IDトークンを検証
  3. ペイロードを取得

の順で処理します。

 

Rubyで署名付きJWTの検証を行うgemについて

Rubyで署名付きJWTの検証を行うgemを探したところ、 ruby-jwt がありました。
jwt/ruby-jwt: A ruby implementation of the RFC 7519 OAuth JSON Web Token (JWT) standard.

また、Gemfile.lockを見ると

oauth2 (1.4.7)
...
  jwt (>= 1.0, < 3.0)
...
omniauth-oauth2 (1.7.1)
  oauth2 (~> 1.4)

と、 omniauth-oauth2 の依存ですでに jwt (ruby-jwt) がインストールされていました。

そこで、今回は ruby-jwt を使ってIDトークンの検証を行います。

 

IDトークン検証用メソッドの作成

まず、IDトークンの検証用メソッドを作成します。

引数として、

  • IDトークン (id_token)
  • IDトークンの sub の検証用に UserInfo エンドポイントから取得した subject の値 (subject_from_userinfo)

を用意します。

def id_token_payload(id_token, subject_from_userinfo)
  # ...
end

 

IDトークン検証の方向性 (JWT.decode)

続いて、

  1. IDトークンのデコード
  2. IDトークンの署名の検証
  3. IDトークンの中身の検証

を行います。

ruby-jwt では decode メソッドを使うことで一括で処理できるようです。

 
IDトークンの署名の検証では、OPの公開鍵を使います。

事前のOPの公開鍵をRPへ保存しておくことも考えられますが、今回はIDトークンの署名を検証するたびにOPの公開鍵エンドポイントから取得するようにします。

そのため、 decode メソッドを

  • 第2引数には nil を渡す
    • 固定の公開鍵を使う場合はここで渡せるが、今回は動的に公開鍵を取りに行くため、鍵は渡さない
  • encode メソッドのブロックとして、公開鍵を探しに行く処理を実装する

のように使います。

なお、公開鍵のアルゴリズムには OP で公開鍵を作成する時に使った

RSA using SHA-256 hash algorithm

である RS256 を指定します。

 
ここまでの decode メソッドの形は

JWT.decode(
  id_token, # JWT
  nil, # key: 署名鍵を動的に探すのでnil
  true, # verify: IDトークンの検証を行う
  { # options
    algorithm: 'RS256', # 署名は公開鍵方式なので、RS256を指定
  }
) do |jwt_header|
  # このブロックの中で、OPの公開鍵情報を取得
end

となります。

 

IDトークンの署名に使った、OPの公開鍵情報を取得

IDトークンの署名鍵として、OPの公開鍵情報を取得します。

OPの公開鍵エンドポイントは /oauth/discovery/keys です。

このエンドポイントアクセスするために、 oauth2 gemの依存としてインストール済の Faraday を使います。

 
なお、公開鍵エンドポイントのレスポンスはJSON形式です。また、公開鍵が1本だけだとしても、

{"keys"=>[
    { "kty"=>"RSA",
      "kid"=>"***",
      "e"=>"AQAB", 
      "n"=>"***", 
      "use"=>"sig", 
      "alg"=>"RS256"
    }
  ]
}

のように、キー keys の中に Array で入っています。

これらを踏まえた実装は以下の通りです。

def fetch_public_keys
  response = Faraday.get("#{ENV['OIDC_PROVIDER_HOST']}/oauth/discovery/keys")
  keys = JSON.parse(response.body)
  keys['keys']
end

 

IDトークンの署名の検証で使う公開鍵を生成

上記でOPの公開鍵情報は取得できました。

ただ、

  • OPの公開鍵そのものは取得できていない
  • 取得した公開鍵情報が、IDトークンの署名したときの鍵なのか分からない

のため、公開鍵情報をそのまま使うことができません。

そこで

  1. OPから取得した公開鍵情報の kid とIDトークンのヘッダーの kid が一致する公開鍵情報で署名したとみなす
  2. 公開鍵情報を元に、公開鍵を作成する

という2つの処理が必要になります。

なお、2.については、 ruby-jwtimport メソッドを使うことで、公開鍵情報から公開鍵を取得できるようです。
Ruby RSA from exponent and modulus strings - Stack Overflow

これらを decode メソッドのブロックに実装します。

JWT.decode(
  # ...
) do |jwt_header|
  # このブロックの中で、OPの公開鍵情報を取得
  # IDトークンのヘッダーのkidと等しい公開鍵情報を取得
  key = fetch_public_keys.find do |k|
    k['kid'] == jwt_header['kid']
  end

  # 等しいkidが見当たらない場合はエラー
  raise JWT::VerificationError if key.blank?

  # 公開鍵の作成
  JWT::JWK::RSA.import(key).public_key
end

 

IDトークンの検証

IDトークンの検証については、 decode メソッドのオプションに verify_iss: true などと設定することで、関係する各 claim を検証できます。
https://github.com/jwt/ruby-jwt#issuer-claim

 

IDトークンのペイロードを取得

ここまでの情報と

  • id_token
  • UserInfoエンドポイントから取得したsubject

より、IDトークンのペイロードを取得するメソッドが完成しました。

メソッドの全体像は以下となります。

def id_token_payload(id_token, subject_from_userinfo)
  # decodeできない場合はエラーを送出する
  payload, _header = JWT.decode(
    id_token, # JWT
    nil, # key: 署名鍵を動的に探すのでnil https://github.com/jwt/ruby-jwt#finding-a-key
    true, # verify: IDトークンの検証を行う
    { # options
      algorithm: 'RS256', # 署名は公開鍵方式なので、RS256を指定
      iss: ENV['ISSUER_OF_MY_OP'],
      verify_iss: true,
      aud: ENV['CLIENT_ID_OF_MY_OP'],
      verify_aud: true,
      sub: subject_from_userinfo,
      verify_sub: true,
      verify_expiration: true,
      verify_not_before: true,
      verify_iat: true
    }
  ) do |jwt_header|
    # このブロックの中で、OPの公開鍵情報を取得
    # IDトークンのヘッダーのkidと等しい公開鍵情報を取得
    key = fetch_public_keys.find do |k|
      k['kid'] == jwt_header['kid']
    end

    # 等しいkidが見当たらない場合はエラー
    raise JWT::VerificationError if key.blank?

    # 公開鍵の作成
    # https://stackoverflow.com/a/57402656
    JWT::JWK::RSA.import(key).public_key
  end

  payload
end

 

OmniAuthの AuthHash に入れる値を定義

OmniAuthでは、認証後の情報を AuthHash に入れます。

 
今回の場合、AuthHashには

を入れます。

具体的には

  • uid
    • UserInfoエンドポイントで取得した sub
      • IDトークンと一致することを確認済
  • info
    • UserInfoエンドポイントで取得した email
  • extra
    • raw_info
      • UserInfoエンドポイントで取得した情報
    • id_token
      • トークンエンドポイントから取得した値そのもの
    • id_token_payload

を指定します。

class MyOp < OmniAuth::Strategies::OAuth2
  uid do
    raw_info['sub']
  end

  info do
    {
      email: raw_info['email']
    }
  end

  extra do
    # access_token.params に hash として id_token が入っている
    # (他に、token_type='Bearer', scope='openid', created_at=<timestamp> が入ってる)
    id_token = access_token['id_token']

    {
      raw_info: raw_info,
      id_token_payload: id_token_payload(id_token, raw_info['sub']),
      id_token: id_token
    }
  end

  def raw_info
    # raw_infoには、UserInfoエンドポイントから取得できる情報を入れる
    @raw_info ||= access_token.get("#{ENV['oidc_provider_host']}/oauth/userinfo").parsed
  end

 

RPでログインするユーザー用Modelの追加

OmniAuthにてAuthHashを作成するまでの設定が終わったため、OminAuthを使ってOPからユーザー情報を受け取れるようになりました。

次は、OPのユーザーをRPのユーザーとしてModel OpUser に保存し、RPのログイン/ログアウトに使用できるようにします。

OpUser には

  • provider
    • Userが所属するOP
  • uid
  • email
    • UserInfoエンドポイントで取得する claim に含まれる email
    • ただし、今回はOP Userのemail変更の同期はしないため、OPと値が異なる可能性あり

を項目として用意します。

% bin/rails g model op_user provider uid email
Running via Spring preloader in process 968
      invoke  active_record
      create    db/migrate/20210813014029_create_op_users.rb
      create    app/models/op_user.rb

 
また、 OpUserprovideruid で一意になることから、一意制約のindexをマイグレーションファイル <TIMESTAMP>_create_op_users.rb に追加しておきます。

class CreateOpUsers < ActiveRecord::Migration[6.1]
  def change
    create_table :op_users do |t|
      t.string :provider
      t.string :uid
      t.string :email

      t.timestamps
    end

    # 追加
    add_index :op_users, %i[provider uid], unique: true
  end
end

 

マイグレーションを実行

たまっているマイグレーションを実行します。

% bin/rails db:migrate

 

ログイン・ログアウトを管理するためのControllerを追加

続いて、ログイン・ログアウトを管理するためのControllerとして、 SessionsController を生成します。

% bin/rails g controller sessions create destroy
Running via Spring preloader in process 6477
      create  app/controllers/sessions_controller.rb
       route  get 'sessions/create'
get 'sessions/destroy'
      invoke  erb
      create    app/views/sessions
      create    app/views/sessions/create.html.erb
      create    app/views/sessions/destroy.html.erb
      invoke  helper
      create    app/helpers/sessions_helper.rb
      invoke  assets
      invoke    css
      create      app/assets/stylesheets/sessions.css

 
ここで、 create は認証レスポンス後の「リダイレクトURI」のアクションとなります。

また、 create アクションが呼ばれる前に、OmniAuthが

  • トークンエンドポイントやUserInfoエンドポイントにアクセス
  • IDトークンを検証
  • AuthHashを設定

を行っているため、 create アクションが呼ばれた時点でユーザーは認証済とみなせます。

 
そこで、 create アクションでのログイン処理では、

を行います。

一方、 destroy メソッドのログアウト処理では reset_session するだけです。

 
以上より、 app/contorllers/sessions_controller.rb の全体像は以下となります。

class SessionsController < ApplicationController
  # ログイン情報しかセッションに入れていないため、セッション情報の移し替えは不要
  before_action :reset_session

  def create
    op_user = OpUser.find_or_create_from_auth_hash!(request.env['omniauth.auth'])
    session[:user_id] = op_user.id
    redirect_to root_path, notice: 'ログインしました'
  end

  def destroy
    # 今のところ、OPのセッションはそのまま残る
    redirect_to root_path, notice: 'ログアウトしました'
  end
end

 

ApplicationControllerに、現在のユーザーを取得するヘルパーを追加

Viewから現在のユーザー情報を取得できるようにするため、ApplicationControllerにヘルパーメソッド current_user を追加します。
ruby on rails - What do helper and helper_method do? - Stack Overflow

class ApplicationController < ActionController::Base
  helper_method :current_user

  private

  def current_user
    # セッションのユーザーIDに紐づくOpUserが存在しない場合は、ログインしていないとみなす
    @current_user ||= OpUser.find_by(id: session[:user_id]) if session[:user_id]
  end
end

 

OpUserモデルに、AuthHashを元に生成 or 取得するメソッドを作成

SessionsController#create から呼ばれるクラスメソッド find_or_create_from_auth_hash! を作成します。

find_or_create_from_auth_hash! では、OmniAuthの AuthHash から該当する OpUser を取得します。

なお、AuthHashは以下のような内容です。

{
  "provider"=>"my_op",
  "uid"=>"1",
  "info"=>#<OmniAuth::AuthHash::InfoHash email="foo@example.com">,
  "credentials"=>#<OmniAuth::AuthHash
    expires=true
    expires_at=1628605105
    token="ACCESS_TOKEN">,
  "extra"=>#<OmniAuth::AuthHash
    id_token="HEADER.PAYLOAD.SIGNATURE"
    id_token_payload=#<Hashie::Array [
      #<OmniAuth::AuthHash
        aud="einUOs09X1VB8N9H_ZBo7iVVCRc2Qzc6eRUyiWQQHJU"
        exp=1628598025
        iat=1628597905
        iss="http://localhost:3780"
        sub="1">,
      #<OmniAuth::AuthHash
        alg="RS256"
        kid="Aa28UkVlSquSf4rtBSwDX0XnNda8et8y2OoUZV3EBg0"
        typ="JWT">
    ]>
    raw_info=#<OmniAuth::AuthHash
      email="foo@example.com"
      sub="1"
>>}

 
そこで、 auth_hash から OpUser モデルに必要な

  • provider
  • uid
  • email

を取り出し、検索 or 作成を行います。

class OpUser < ApplicationRecord
  def self.find_or_create_from_auth_hash!(auth_hash)
    provider = auth_hash[:provider]
    uid = auth_hash[:uid]
    email = auth_hash[:info][:email]

    OpUser.find_or_create_by!(provider: provider, uid: uid) do |op_user|
      op_user.email = email
    end
  end
end

 

認証リクエストを送るために、ControllerとViewを追加

今回は OIDC の認証リクエストを送るための画面として、 Home Viewを用意します。

Home Viewには以下の項目を表示します。

  • 未ログインの場合
    • ログインボタン
  • ログイン済の場合
    • ログアウトボタン
    • ログインユーザーのemail
    • 再度ログインするためのボタン

 
railsコマンドで生成します。

% bin/rails g controller home index
Running via Spring preloader in process 1478
      create  app/controllers/home_controller.rb
       route  get 'home/index'
      invoke  erb
      create    app/views/home
      create    app/views/home/index.html.erb
      invoke  helper
      create    app/helpers/home_helper.rb
      invoke  assets
      invoke    css
      create      app/assets/stylesheets/home.css

 
app/views/home/index.html.erb を編集します。

今回は layouts は使用せず、1つのViewにすべてを詰め込みます。

そのため、

  • OmniAuthのエンドポイント ( /auth/my_op ) へ POST するためのボタンを追加
  • ログインしている時は、ログアウトボタンを表示
  • flashメッセージを表示

を追加します。

<h1>RP Home</h1>
<div>
  <% if flash[:notice] %>
    <p>
      <%= flash[:notice] %>
    </p>
  <% end %>

  <% if current_user %>
    <p>Logged in as <strong><%= current_user.email %></strong></p>
    <p><%= link_to 'Logout', logout_path, id: 'logout' %></p>

    <div>Re Auth
      <%= form_tag('/auth/my_op', method: 'post') do %>
        <button type='submit'>Re Login</button>
      <% end %>
    </div>
  <% else %>
    <%= form_tag('/auth/my_op', method: 'post') do %>
      <button type='submit'>Login</button>
    <% end %>
  <% end %>
</div>

 

routes.rb を編集

ここまででルーティングに必要なものはすべて実装したため、 config/routes.rb を編集します。

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

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

  # 認証に失敗したときのルーティング
  get 'auth/failure', to: redirect('/')

  # ログアウト
  get 'logout', to: 'sessions#destroy'
end

 

動作確認

ここまでの実装ではセキュリティまわりに不備があるものの、OP・RPが動作するようになったため、動作確認をしてみます。

 

OPの起動

まずは OP を起動します。

# OPのディレクトリにて
% bin/rails s

 

OPにユーザーを作成

deviseでユーザーを作成します。

以下のURLにアクセスします。
http://localhost:3780/users/sign_up

今回は

項目
Email foo@example.com
Passowrd password

で登録します。

 

OPに、RPのアプリを追加する

doorkeeperの機能を使って、OAuth Applicationを登録します。

ログインした状態で、以下のURLにアクセスします(ログインしていない場合、ログイン画面へリダイレクトします)。 http://localhost:3780/oauth/applications

New Application をクリックし、以下の内容でアプリケーションを登録します。

項目
Name backend_rp
Redirect URI http://localhost:3781/auth/my_op/callback
Confidential [x] Application will be used ...
Scopes openid

 
画面に UIDSecret が表示されるため、 RPの .env ファイルに

キー
CLIENT_ID_OF_MY_OP UIDの値
CLIENT_SECRET_OF_MY_OP Secretの値

を追加します。

 

RPの起動

続いて、RPを起動します。

# RPのディレクトリにて
% bin/rails s

 

ログインを試す

RPのルートへアクセスします。 http://localhost:3781/

 
ログインボタンをクリックすると、OPのログインページへ遷移します。

 
ログインすると、同意画面が表示されます。

 
同意すると、RPに戻り、ログイン状態となります。

 
RPのログアウトボタンをクリックすると、ログアウトします。

 
RPのOpUserテーブルにも登録されています。

 
以上より、動作は良さそうです。

 

セキュリティ対策を追加

ここまでで RP <---> OP 間の動作確認はできましたが、まだセキュリティまわりに不備があります。

そのため、以下で記載されているセキュリティまわり対策の機能を追加していきます。

 

state

state ですが、RP側の omniauth-oauth2 では実装されています。
例: https://github.com/omniauth/omniauth-oauth2/blob/v1.7.1/lib/omniauth/strategies/oauth2.rb#L63

OP側の doorkeeper でも実装されています。
例: https://github.com/doorkeeper-gem/doorkeeper/blob/v5.5.2/lib/doorkeeper/oauth/code_response.rb#L25

 
また、ログを見ても、OP側は認可リクエストを受け取った時に state も受け取っています。

...
Started GET "/oauth/authorize?client_id=***&redirect_uri=***&response_type=code&scope=openid
&state=b68d3459455c7842083049f6f793c3f57098488196d5888b"
for 127.0.0.1 at 2021-08-13 19:22:21 +0900
...

 
RP側も、認可レスポンスの時に code の他に state も受け取っています。

Started POST "/auth/my_op" for 127.0.0.1 at 2021-08-13 19:22:18 +0900
Started GET "/auth/my_op/callback?code=***
&state=b68d3459455c7842083049f6f793c3f57098488196d5888b"
for 127.0.0.1 at 2021-08-13 19:22:21 +0900
...

 
これらより、 state は OP (doorkeeper) + RP (omniauth-oauth2) であれば、すでに実装されていることが分かりました。

 

PKCE

RP側のOmniAuthでは、READMEに従い、optionで PKCE の設定が必要です。
https://github.com/omniauth/omniauth-oauth2#creating-an-oauth2-strategy

module OmniAuth
  module Strategies
    class MyOp < OmniAuth::Strategies::OAuth2
      # ...
      # PKCEを使うように設定
      option :pkce, true
# ...

 
OP側のdoorkeeperでは、Wikiの記載に従い、PKCEを有効にします。
Using PKCE flow · doorkeeper-gem/doorkeeper Wiki

% bin/rails g doorkeeper:pkce
Running via Spring preloader in process 20723
      create  db/migrate/20210813110825_enable_pkce.rb

 
新しくマイグレーションファイルができたため、マイグレーションを実行します。

% bin/rails db:migrate

 
設定が終わったため、動作確認をします。

今までと同じくログインした後、OP側のログを確認します。

まずは認証リクエストのパラメータに

  • code_challenge
  • code_challenge_method

が追加されています。

Started GET "/oauth/authorize?client_id=***
&code_challenge=E7JbOKJ6DMO7JgsoztSmJdSOIFvRgYanN2BnR6EN0Ns
&code_challenge_method=S256
&redirect_uri=***&response_type=code&scope=openid&state=***" for 127.0.0.1 at 2021-08-13 20:10:50 +0900

 
また、トークンリクエストの際にも code_verifier が追加されています。

Started POST "/oauth/token" for 127.0.0.1 at 2021-08-13 20:10:50 +0900
Processing by Doorkeeper::TokensController#create as */*
  Parameters: {
      "client_id"=>"***", "client_secret"=>"[FILTERED]", "code"=>"[FILTERED]", 
"code_verifier"=>"34dd455edad41cedcc299fdc91c795f2aa9964fbde2e0b134c732322db62dfaa2094132fdfd5b3d4cfe766e508156345eb910ec6c2240e230faf2835f7e0afc9", 
      "grant_type"=>"authorization_code", "redirect_uri"=>"***"}

 
これにより、OP (doorkeeper) + RP (omniauth-oauth2) であれば、gemにあるPKCEの設定を追加すれば良いことが分かりました。

 

nonce

最後に nonce についてです。

nonceOpenID Connect で定義されたパラメータのため、OmniAuth OAuth2 では実装されていません。

そのため、RP側は独自に実装する必要があります。

 
一方、OP側 (doorkeeper) ですが、過去に doorkeeper 本体にPRが出ていたものの、reject されていました。

 
そこで、doorkeeper-openid_connect のREADMEを見ると、nonce に関する記載がありました。
https://github.com/doorkeeper-gem/doorkeeper-openid_connect#nonces

そのため、OP側はdoorkeeper-openid_connect で実装されているようです。

 

RP側を実装

RP側では

  • 認証リクエスト時に nonce を生成して、リクエストパラメータに追加
    • IDトークンの検証時に利用できるよう、生成した nonce はsessionに入れておく
  • トークンレスポンスで受け取る IDトークンに nonce が含まれるため、IDトークンの検証時の検証を追加する
    • nonce は一回のみ利用可能なため、IDトークンの検証時にセッションから nonce を削除する

の実装が必要になります。

なお、上記機能は「認証リクエスト〜トークンレスポンス」の間で必要なため、OmniAuthのストラテジーで実装します。

 
まずは、 nonce を生成するメソッドとして generate_nonce を用意します。

nonce の仕様を探したところ、以下の記事に記載がありました。ありがとうございます。
OAuth 2.0 / OpenID Connectにおけるstate, nonce, PKCEの限界を意識する - r-weblife

今回はRubySecureRandom#urlsafe_base64 を使って、安全な乱数発生器によるURLセーフなbase64文字列を生成します。
https://docs.ruby-lang.org/ja/latest/class/SecureRandom.html#S_URLSAFE_BASE64

また、生成した値はセッションに保存します。

def generate_nonce
  session['omniauth.nonce'] = SecureRandom.urlsafe_base64
end

 
次に、認証リクエストのパラメータに nonce を追加します。

OmniAuth OAuth2 で認証リクエストのパラメータを追加するには、メソッド authorize_params をオーバーライドすれば良さそうです。
https://github.com/omniauth/omniauth-oauth2/blob/v1.7.1/lib/omniauth/strategies/oauth2.rb#L62

def authorize_params
  super.tap do |params|
    params[:nonce] = generate_nonce
  end
end

ここまでで、認証リクエストまわりの実装が終わりました。

 
続いて、IDトークンの検証時に nonce も検証する処理を追加します。

まずはセッションから nonce を取り出すメソッドを用意します。

def nonce_from_session
  # nonceは再利用できないので、取り出したらsessionから消しておく
  session.delete('omniauth.nonce')
end

 
次に、IDトークンのペイロードに含まれる nonce を検証するためのメソッド verify_nonce! を実装します。

今回のRPは常に nonce を送信するため

  • IDトークンに nonce があること
  • IDトークンとセッションとで nonce が一致すること

を検証します。

def verify_nonce!(payload)
  # debug用
  nonce = nonce_from_session
  puts "session ===> #{nonce}"
  puts "payload ===> #{payload['nonce']}"
  return if payload['nonce'] && payload['nonce'] == nonce

  raise JWT::VerificationError
end

 
あとは、IDトークンのペイロードを取得した後に verify_nonce! メソッドの呼び出しを追加します。

def id_token_payload(id_token, subject_from_userinfo)
  # decodeできない場合はエラーを送出する
  payload, _header = JWT.decode(
    # ...
  ) do |jwt_header|
    # ...
  end

  # nonceの確認
  verify_nonce!(payload)

  payload
end

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

 

OP側を実装

READMEに従って実装します。
https://github.com/doorkeeper-gem/doorkeeper-openid_connect#nonces

doorkeeperのViewをカスタマイズする必要があるため、viewを生成します。

% bin/rails generate doorkeeper:views
Running via Spring preloader in process 22964
      create  app/views/doorkeeper
      create  app/views/doorkeeper/applications/_delete_form.html.erb
      create  app/views/doorkeeper/applications/_form.html.erb
      create  app/views/doorkeeper/applications/edit.html.erb
      create  app/views/doorkeeper/applications/index.html.erb
      create  app/views/doorkeeper/applications/new.html.erb
      create  app/views/doorkeeper/applications/show.html.erb
      create  app/views/doorkeeper/authorizations/error.html.erb
      create  app/views/doorkeeper/authorizations/form_post.html.erb
      create  app/views/doorkeeper/authorizations/new.html.erb
      create  app/views/doorkeeper/authorizations/show.html.erb
      create  app/views/doorkeeper/authorized_applications/_delete_form.html.erb
      create  app/views/doorkeeper/authorized_applications/index.html.erb
      create  app/views/layouts/doorkeeper
      create  app/views/layouts/doorkeeper/admin.html.erb
      create  app/views/layouts/doorkeeper/application.html.erb

 
生成されたViewのうち、 app/views/doorkeeper/authorizations/new.html.erb を開き、 nonce を hidden として追加します。

なお、PKCE用の

  • code_challenge
  • code_challenge_method

の2つの hidden は削除しないように注意します。
https://github.com/doorkeeper-gem/doorkeeper/wiki/Using-PKCE-flow#enable-pkce-in-doorkeeper

<main role="main">
  <p class="h4">
    <%= raw t('.prompt', client_name: content_tag(:strong, class: 'text-info') { @pre_auth.client.name }) %>
  </p>

  <% if @pre_auth.scopes.count > 0 %>
    <div id="oauth-permissions">
      <p><%= t('.able_to') %>:</p>

      <ul class="text-info">
        <% @pre_auth.scopes.each do |scope| %>
          <li><%= t scope, scope: [:doorkeeper, :scopes] %></li>
        <% end %>
      </ul>
    </div>
  <% end %>

  <div class="actions">
    <%= form_tag oauth_authorization_path, method: :post do %>
      ...
      <%= hidden_field_tag :nonce, @pre_auth.nonce %>
      ...
    <%= form_tag oauth_authorization_path, method: :delete do %>
      ...
      <%= hidden_field_tag :nonce, @pre_auth.nonce %>
      ...

 

動作確認

ログインが完了した後、OP・RPのログを確認します。

OPのログにて、認証リクエストの際 nonce が送信されていました。

Started GET "/oauth/authorize?client_id=***&code_challenge=***&code_challenge_method=***
&nonce=PTY0zJhU-Czk7WQ4l7hxjg
&redirect_uri=***&response_type=code&scope=openid&state=***" for 127.0.0.1 at 2021-08-13 21:30:44 +0900

 
また、 RPのログにて、 puts した nonce

session ===> PTY0zJhU-Czk7WQ4l7hxjg
payload ===> PTY0zJhU-Czk7WQ4l7hxjg

と出力されていました。

これにより、 nonce の動作を確認できました。

 

ソースコード

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

また、 rails new 以降の OP/RPの実装は、以下のPRにまとめました。
https://github.com/thinkAmi-sandbox/oidc_op_rp-sample/pull/1