エムスリーテックブログ

エムスリー(m3)のエンジニア・開発メンバーによる技術ブログです

フルスクラッチして理解するOpenID Connect (1) 認可エンドポイント編

こんにちは。デジカルチームの末永(asmsuechan)です。

この記事では、OpenID Connect の ID Provider を標準ライブラリ縛りでフルスクラッチすることで OpenID Connect の仕様を理解することを目指します。実装言語は TypeScript です。

記事のボリュームを減らすため、OpenID Connect の全ての仕様を網羅した実装はせず、よく使われる一部の仕様のみをピックアップして実装します。この記事は全4回中の第1回となります。

なお、ここで実装する ID Provider は弊社内で使われているものではなく、筆者が趣味として作ったものです。ですので本番環境で使用されることを想定したものではありません。なんなら私は ID Provider を運用する仕事もしておりません。

(2024/06/09) 技術書典で改訂版を出しました。 現在GitHubにpushされているコードは技術書典版にアップデートされていますが、大きな変更はありません。

techbookfest.org


完成した自作ID Providerはこちらになります。

github.com

今回は全4回中の第1回目です。

  • (1) 認可エンドポイント編
  • (2) トークンエンドポイント編
  • (3) JWT編
  • (4) stateとnonce編

1 OAuth 2.0 と OpenID Connect

よくある説明ですが、OAuth は認可のための仕様で、OpenID Connect は認証のための仕様です。OpenID Connect は OAuth 2.0 の仕様を元に作られています。

実装者側の具体的な説明をすると、連携相手のデータを操作するのが OAuth で、連携相手のユーザー ID とパスワードを使い自分のアプリケーションを操作するのが OpenID Connect です。OAuth ではアクセストークンを連携相手に送信し、OpenID Connect では OpenID Connect のサーバーから降ってきた ID トークンを使って自分のサーバーにデータを作ります。

  • OAuth 2.0
    • 認可のための仕様
    • アクセストークン
    • アクセストークンを使って連携先と通信
  • OpenID Connect
    • 認証のための仕様
    • OAuth 2.0 を元に作られた
    • ID トークン
    • ID トークンを使って自分のアプリケーションにユーザーを作る

OAuth は「連携相手の機能と連携できるため、自分のアプリケーションの機能が増えて便利になる」ことが多く、OpenID Connect は「連携相手の ID とパスワードで自分のアプリケーションにログインできるため管理が楽になる」ことが多くなるという認識です。

仕様 操作 トークン
OAuth 連携先 アクセストークン ブログシステムで Facebook への同時投稿
OpenID Connect 自分のアプリ ID トークン Facebook ログイン

結局のところ、どちらの仕様も「安全にアクセストークン or/and ID トークンをアプリケーションに渡す」ことがゴールです。

1.1 用語の整理

用語(OAuth での名称) 説明 補足
ID Provider(認可サーバー) OpenID Connect において ID トークンを発行するサーバー IdP と略される
RelyingParty(クライアント) ID Provider から ID トークンを受け取るアプリケーション RP と略される
アクセストークン OAuth の文脈で発行されるトークン トークンの形式は定められていない
ID トークン OpenID Connect の文脈で発行されるトークン トークン自体に署名が必要。JWT が使われる

1.2 連携の流れ (認可コードフロー)

OAuth と OpenID Connect にはトークンを取得するためにいくつかのフローがあります。ここでは認可コードフローの流れの図を示します。

認可フローの流れ(1)

認可コードフローの流れ(2)

認可コードフローはざっくり言うと

  • RelyingParty が認可コードを取得する
  • RelyingParty は認可コードを使ってアクセストークン/ID トークンを取得する

の流れでトークンを取得するフローです。

2 tiny-idpことはじめ

さて、それではこれから ID Provider をフルスクラッチで実装していきます。この ID Provider は tiny-idp という名前にします。

2.1 注意事項

前提として、これから実装する tiny-idp は筆者が OpenID Connect の理解に重要だと思った仕様をメインに実装していっています。

  • 認可コードフローのみ実装
  • クライアント認証は client_secret_post のみ実装
  • prompt や display などの細かい仕様は実装しない
  • リフレッシュトークン、ログアウトも実装しない
  • client_id は tiny-client で固定
  • HTTP 通信部分は標準ライブラリの http パッケージを利用
    • express とか使ってもよかったけど縛りプレイしたかったため
  • データはインメモリに保存して RDB などは使わない

などの制限をし、OpenID Connect の全仕様を網羅した実装はしていません。仕様で MUST になっている部分はなるべく実装します。

2.2 作るもの

次のものを作ります。

  • ID Provider の Web アプリケーションサーバー (tiny-idp)
  • 署名ロジックを含んだ JWT の生成クラス
  • JWT を検証するコード
  • 検証用の RelyingParty (tiny-rp)

ID Provider の Web アプリケーション的な部分だけではなく、JWT の生成部分も標準ライブラリを使って作ります。また、検証用に RelyingParty も作っていきます(こっちは少しライブラリを使います)。

2.3 実装するエンドポイント

tiny-idp では次のエンドポイントを実装します。

  • POST /login
    • ログインページから呼ばれる
    • 成功したら RelyingParty の redirect_uri にリダイレクトする
  • GET /openid-connect/auth
    • 認可エンドポイント
    • ログインページを表示する
  • POST /openid-connect/token
    • トークンエンドポイント
    • アクセストークンと ID トークンを生成する
    • redirect_uriのエンドポイントからアクセスされる
  • POST /openid-connect/introspect
    • アクセストークンの有効性を検証するためのエンドポイント
  • GET /openid-connect/jwks.json
    • JWT を署名した秘密鍵に対応した公開鍵を公開するためのエンドポイント
  • GET /openid-connect/.well-known/openid-configuration
    • 各種設定値や接続先などを公開するためのエンドポイント

3 tiny-rp を作る

まず最初に、発行されたトークンを受け取るサーバーを立てておくと動作確認に便利なので RelyingParty も簡単に作っておきます。ザッと作って後でちょくちょく修正します。

この RelyingParty が一般的に OpenID Connect を利用する場合に実装する部分となります。

3.1 TypeScript プロジェクトを始める

https://typescript-jp.gitbook.io/deep-dive/nodejs

上記を参考に TypeScript で新規プロジェクトを作ります。

$ npm install express openid-client

標準ライブラリ縛りは tiny-idp だけなのでここでは express と openid-client を使っています。かなり薄くしか使っていないので実装の概要を理解する妨げにはならないと思います。

3.2 RelyingParty 本体の実装

src/index.ts に RelyingParty の本体を実装します。

詳しい説明は省きますが、次のことをしています。

  • ログインボタンを押すと IdP のログインページが開く
  • IdP からのリダイレクトを受け取ってトークン取得リクエストを IdP に送る
  • 取得したトークンを検証する
import express from "express";
import { Issuer } from "openid-client";

const app = express();
const port = 4000;

const issuer = new Issuer({
  issuer: "http://localhost:3000",
  authorization_endpoint: "http://localhost:3000/openid-connect/auth",
  token_endpoint: "http://localhost:3000/openid-connect/token",
  jwks_uri: "http://localhost:3000/openid-connect/jwks",
});
const { Client } = issuer;
const client = new Client({
  client_id: "tiny-client",
  client_secret: "hoge",
});

app.get("/", async (req, res) => {
  const authorizationUri = client.authorizationUrl({
    redirect_uri: "http://localhost:4000/oidc/callback",
    scope: "openid",
  });
  res.send(`<!DOCTYPE html>
<html>
<head>
    <title>tiny-rp</title>
</head>
<body>
    <div><h1>tiny-idp Login</h1></div>
    <div><a href="${authorizationUri}">Login</a></div>
</body>
</html>`);
});

// redirect_uriをここに実装
// トークンエンドポイントを叩く
app.get("/oidc/callback", async (req, res) => {
  const redirect_uri = "http://localhost:4000/oidc/callback";
  const code = String(req.query.code);
  const scope = String(req.query.scope);

  try {
    const tokenResponse = await fetch(
      "http://localhost:3000/openid-connect/token",
      {
        method: "POST",
        headers: {
          "Content-Type": "application/x-www-form-urlencoded",
        },
        body: new URLSearchParams({
          code,
          redirect_uri,
          scope,
          grant_type: "authorization_code",
          client_id: "tiny-client",
        }),
      }
    );
    const tokenSet = await tokenResponse.json();
    console.log(tokenSet);
    // TODO: トークンを検証するコードは後で追加します
    res.status(200);
    res.json({ tokenSet });
    return;
  } catch (error) {
    console.error("Access Token Error: ", error);
    res.status(500);
    res.json({ error: "Access Token Error" });
    return;
  }
});

app.listen(port, () => {
  console.log(`Example app listening on port ${port}`);
});

後でトークンの検証を自前実装する予定なので、トークン取得部分は取り回しやすいよう openid-client ではなく fetch を使っています。

関係ないですが、RelyingParty をちゃんと実装したサンプルってネット上を探しても少ないですよね。

localhost:4000 にアクセスすると次の画面が表示されます。

4 tiny-idp を作る

では、手を動かしながら tiny-idp を作っていきます。

4.1 TypeScript プロジェクトを始める

https://typescript-jp.gitbook.io/deep-dive/nodejs

tiny-rp と同様に上記を参考に TypeScript で新規プロジェクトを作ります。

4.2 Web アプリケーションサーバーを作る

まずは Web アプリケーションの雛形を作ります。

// src/index.ts
import http, { IncomingMessage, ServerResponse } from "http";

const server = http.createServer(
  (req: IncomingMessage, res: ServerResponse) => {
    console.log(`[${new Date()}] ${req.url}`);
    if (req.url === "/") {
      res.writeHead(200, { "Content-Type": "text/plain" });
      res.end("Hello tiny openid provider!");
    } else {
      res.writeHead(404, { "Content-Type": "text/plain" });
      res.end("Page not found");
    }
  }
);

server.listen(3000);

npm run build && node lib/index.js して立ち上げると localhost:3000 にアクセスできるようになりました。

~/src/asmsuechan/tiny-idp > curl localhost:3000
Hello tiny openid provider!

5 ユーザーとユーザーログインを作る(POST /login)

ID Provider に簡単にユーザー管理を実装します。

ユーザー管理は OIDC の本筋ではないので次のように機能を絞ります。

  • データはインメモリで扱う (MySQL などは使わない)
  • ユーザーの ID/Password は固定値とする
  • パスワードも平文で文字列比較するのみ
  • セッションの保持はしない

簡単なユーザー機能を実装するため、User クラスを作ります。User クラスはプロパティとして id, email, password, clientId を持ちます。password は上述の通り平文です。

プロパティ名 補足
id number 一意の数字
email string 一意のメールアドレス。ログイン ID として機能する
password string パスワード。ここでは平文
cleintId string どの client に作られたユーザーなのかを表す

では、src/models/user.tsを作り次のように記述します。

// src/models/user.ts
export class User {
  id: number;
  email: string;
  password: string;
  clientId: string;

  constructor(id: number, email: string, password: string, clientId: string) {
    this.id = id;
    this.email = email;
    this.password = password;
    this.clientId = clientId;
  }

  static findByEmail(db: User[], email: string) {
    const result = db.find((u) => u.email === email);
    if (result) {
      return new User(result?.id, result?.email, result?.password, result?.clientId);
    } else {
      throw Error("User Not Found");
    }
  };

  static login(db: User[], email: string, password: string) {
    const user = db.find((u) => u.email === email && u.password === password);
    if (user) {
      return true;
    } else {
      return false;
    }
  }
}

login()メソッドはパスワードを平文で比較してユーザーの認証を確認する関数です。

コントローラーでデータを使うための取りまとめとして Context 型を作って使います。

// src/models/context.ts
import { User } from "./user";

export type Context = {
  users: User[];
};

次に login_controller.ts を作ります。なお構成は MVC にしています。

// src/controllers/login_controller.ts
import { ServerResponse } from 'http';
import { ParsedUrlQuery } from 'querystring';
import { User } from '../models/user';
import { Context } from '../models/context';

export const login = (db: Context, query: ParsedUrlQuery, params: URLSearchParams, res: ServerResponse) => {
  const email = params.get('email');
  const password = params.get('password');
  console.log(email, password, db.users);
  if (email && password && User.login(db.users, email, password)) {
    res.writeHead(200, { 'Content-Type': 'application/json' });
    res.end(JSON.stringify({ user: User.findByEmail(db.users, email) }));
  } else {
    res.writeHead(403, { 'Content-Type': 'application/json' });
    res.end(JSON.stringify({ error: 'Unauthorized' }));
  }
};

そして最後に src/index.ts に POST /login のエンドポイントを追加します。if-else の中に以下を追記します。

// src/index.ts
import url from 'url'
import { login } from './controllers/login_controller'
()
// NOTE: インメモリDBを初期化する
const users: User[] = [{ id: 1, email: 'tiny-idp@asmsuechan.com', password: 'p@ssw0rd', clientId: 'tiny-client' }];
const db = { users };

()
  } else if (req.url?.split('?')[0] === '/login' && req.method === 'POST') {
    const query = url.parse(req.url, true).query;
    let body = '';
    req.on('data', (chunk) => {
      body += chunk;
    })
    req.on('end', () => {
      const params = new URLSearchParams(body);
      login(db, query, params, res);
    })

npm run build && node lib/index.js で起動して、curl で動作確認をしてみます。

~/src/asmsuechan/tiny-idp > curl localhost:3000/login -X POST -d 'email=tiny-idp@asmsuechan.com' -d 'password=p@ssw0rd'
{"user":{"id": 1, "email":"tiny-idp@asmsuechan.com","password":"p@ssw0rd","clientId":"tiny-client"}}
~/src/asmsuechan/tiny-idp > curl localhost:3000/login -X POST -d 'email=a' -d 'password=a'
{"error":"Unauthorized"}

ID とパスワードが正しい場合はユーザーの情報が返ってきて、間違っているときはエラーが返ってきました。

6 認可エンドポイントの実装(GET /openid-connect/auth)

認可エンドポイントとは、認可コードを生成して RelyingParty に送るためのエンドポイントです。

6.1 リクエストとレスポンス

認可エンドポイントでは次のパラメーターを受け取ります(state や nonce は後で実装します)。

パラメーター 説明
client_id tiny-client
scope openid
response_type code
redirect_uri http://localhost:4000/oidc/callback

リクエストの Content-Type は application/x-www-form-urlencoded です。

そしてそのレスポンスとして、ログインページが表示されます。

なお、tiny-idp ではセッションを保持しないため、認可エンドポイントを叩くと毎回ログインページが表示されます。

6.2 ログインページと認可エンドポイントを作る

認可エンドポイントのレスポンスとして、ログインのページを返します。ですのでログインページを作っていきます。

client_id や redirect_uri を渡すため、独自に簡単なテンプレートエンジンを実装しています。

この辺りで express などのライブラリを非常に使いたくなったのですが、標準ライブラリ縛りは崩したくなかったのでこのまま進めていきます。お付き合いください。

まずはログインページを作ります。次のような form が 1 つあるシンプルなページです。

<!-- src/views/login.html -->
<!DOCTYPE html>
<html>
  <head>
    <title>tiny-idp Login</title>
  </head>

  <body>
    <div>
      <h1>tiny-idp Login</h1>
    </div>
    <form
      action="http://localhost:3000/login?client_id={client_id}&redirect_uri={redirect_uri}&scope={scope}"
      method="post"
    >
      <div>
        <label for="email">Email</label>
        <input email="email" type="text" name="email" />
      </div>
      <div>
        <label for="password">Password</label>
        <input id="password" type="password" name="password" />
      </div>
      <div>
        <input name="login" type="submit" />
      </div>
    </form>
  </body>
</html>

次に、この view を表示するための controller を書きます。上述のパラメーターを受け取るようにします。

このエンドポイントはログインページを返すので、login.html を作ります。簡単にテンプレートエンジンを実装しており、正規表現でログインに使うエンドポイントのパラメーターを書き換えています。

// src/controllers/auth_controller.ts
import { ParsedUrlQuery } from 'querystring';
import { Context } from '../models/context';
import { ServerResponse } from 'http';
import fs from 'fs';

export const getAuth = (db: Context, query: ParsedUrlQuery, res: ServerResponse) => {
  try {
    const scope = query.scope;
    const clientId = query.client_id;
    const redirectUri = query.redirect_uri;
    // TODO: バリデーションの追加

    const loginPage = fs.readFileSync('src/views/login.html', 'utf8');
    // NOTE: 簡易テンプレートエンジン
    let template = loginPage;
    template = template.replace(/{client_id}/g, String(clientId));
    template = template.replace(/{redirect_uri}/g, String(redirectUri));
    template = template.replace(/{scope}/g, String(scope));
    res.end(template);
  } catch (e) {
    // NOTE: エラー時はserver_errorを返すという仕様も決まっている
    // https://openid-foundation-japan.github.io/rfc6749.ja.html#code-authz-resp
    console.error(e);
    res.writeHead(500, { 'Content-Type': 'application/x-www-form-urlencoded' });
    const responseData = { error: 'server_error' };
    const response = new URLSearchParams(responseData).toString();
    res.end(response);
  }
};

リクエストパラメーターの validation についても RFC で決まっているので、後で実装します。

さて、index.ts を編集して GET /openid-connect/auth にアクセスされたら上記のページを返すようにします。

// src/index.ts
import { getAuth } from './controllers/auth_controller';
()
  } else if (req.url?.split('?')[0] === '/openid-connect/auth' && (req.method === 'GET' || req.method === 'POST')) {
    const query = url.parse(req.url, true).query;
    getAuth(db, query, res);

http://localhost:3000/openid-connect/auth?client_id=tiny-client&redirect_uri=http://localhost:4000/oidc/callback にアクセスしてみます。

先ほどのユーザー(tiny-idp@asmsuechan.com, p@ssw0rd)でログインしてみます。

ログインできました。なお、ログインに失敗すると次のように表示されます。

6.3 パラメーターの検証

リクエスト時に付与したパラメーターをチェックして、適切なものかどうかを判別します。リクエストパラメーターの仕様は以下です。

パラメーター ルール 補足
client_id (必須) tiny-client であること client は tiny-client で固定するため
redirect_uri (必須) http://localhost:4000/oidc/callback RelyingParty のエンドポイント
response_type (必須) code であること 認可コードフローのみサポートするため
scope openid を含むこと スコープの区切り文字はスペース

この scope の値によって userinfo endopoint のレスポンスに含めることができる内容を制御できます。

For OpenID Connect, scopes can be used to request that specific sets of information be made available as Claim Values. via 5.4. Requesting Claims using Scope Values

まず、リクエストの型を作ります。

// src/controllers/auth_controller.ts
// https://openid.net/specs/openid-connect-core-1_0.html#AuthRequest
// https://openid-foundation-japan.github.io/rfc6749.ja.html#code-authz-req
type QueryParams = {
  scope: string | string[] | undefined;
  responseType: string | string[] | undefined;
  clientId: string | string[] | undefined;
  redirectUri: string | string[] | undefined;
};

RFC にしたがってエラー文字列を AuthCodeError 型にします。全部並べると次のようになります。

// src/controllers/auth_controller.ts

// https://www.rfc-editor.org/rfc/rfc6749.html#section-4.1.2.1
// https://openid.net/specs/openid-connect-core-1_0.html#AuthCodeError
type AuthCodeError =
  | "invalid_request"
  | "unauthorized_client"
  | "access_denied"
  | "unsupported_response_type"
  | "invalid_scope"
  | "server_error"
  | "temporarily_unavailable"
  | "interaction_required"
  | "login_required"
  | "account_selection_required"
  | "consent_required"
  | "invalid_request_uri"
  | "invalid_request_object"
  | "request_not_supported"
  | "request_uri_not_supported"
  | "registration_not_supported";

次に、バリデーションのレスポンスの型を作ります。エラーの通知先は、リソースオーナーかリダイレクト URI の 2 通りあり、バリデーションエラーの結果によってどちらにエラーを返すか変わります。

リクエストが, リダイレクト URI の欠落 / 不正 / ミスマッチによって失敗した場合, もしくはクライアント識別子が不正な場合は, 認可サーバーはリソースオーナーにエラーを通知すべきである (SHOULD). 不正なリダイレクト URI に対してユーザーエージェントを自動的にリダイレクトさせてはならない (MUST NOT).
リソースオーナーがアクセス要求を拒否した場合, もしくはリダイレクト URI の欠落や不正以外でリクエストが失敗した場合は, 認可サーバーは application/x-www-form-urlencoded (Appendix B) フォーマットを用いてリダイレクト URI のクエリーコンポーネントに次のようなパラメーターを付与してクライアントに返却する.
https://openid-foundation-japan.github.io/rfc6749.ja.html#rfc.section.4.1.2.1

// src/controllers/auth_controller.ts
type ErrorTarget = "resourceOwner" | "redirectUri";
type ValidateError = {
  authCodeError: AuthCodeError;
  target: ErrorTarget;
};

さて、では validation 本体を作ります。ちょっと込み入っていますが RFC を読みながら 1 つ 1 つ作ります。

まずは invalid_request についての検証を作ります。仕様は以下です。

invalid_request
リクエストに必須パラメーターが含まれていない, サポート外のパラメーターが付与されている, 同一のパラメーターが複数含まれる場合, その他不正な形式であった場合もこれに含まれる.

「同一のパラメーターが複数含まれる場合」は ?client_id=hoge&client_id=huga のパターンです。また、上記の必須パラメーターの存在も確認します。

// src/controllers/auth_controller.ts

// (略)
// NOTE: エラーの返却先はリソースオーナーとredirect_uriの2種類ある
// https://openid-foundation-japan.github.io/rfc6749.ja.html#code-authz-resp
// https://openid.net/specs/openid-connect-core-1_0.html#AuthCodeError
const validate = (query: QueryParams): ValidateError | null => {
  const validRedirectUris = ["http://localhost:4000/oidc/callback"];
  const validClientIds = ["tiny-client"];
  const redirectUri = query.redirectUri;
  const clientId = query.clientId;
  if (
    !redirectUri ||
    Array.isArray(redirectUri) ||
    !validRedirectUris.includes(redirectUri) ||
    !clientId ||
    Array.isArray(clientId) ||
    !validClientIds.includes(clientId)
  ) {
    return { authCodeError: "invalid_request", target: "resourceOwner" };
  }
  // TODO: 他のバリデーションを追加していく
  return null;
};

次に、エラーをリダイレクト先に返すパターンを実装します。

// src/controllers/auth_controller.ts

()
  const responseType = query.responseType;
  // NOTE: scopeの区切り文字はスペース
  const scope = query.scope;

  // NOTE: パラメーターがnullでない、かつ?client_id=a&client_id=bのように複数指定されていないことの確認
  // > リクエストに必須パラメーターが含まれていない, サポート外のパラメーターが付与されている, 同一のパラメーターが複数含まれる場合, その他不正な形式であった場合もこれに含まれる.
  // https://openid-foundation-japan.github.io/rfc6749.ja.html#code-authz-resp
  if (!responseType || !scope || Array.isArray(responseType) || Array.isArray(scope)) {
    return { authCodeError: 'invalid_request', target: 'redirectUri' };
  }
  // ここまで追加

  return null;
};

最後に response_type と scope の値が tiny-idp で有効なものかを検証します。

// src/controllers/auth_controller.ts

  const validResponseTypes = ['code'];
  if (!validResponseTypes.includes(responseType)) {
    return { authCodeError: 'unsupported_response_type', target: 'redirectUri' };
  }
  const validScopes = ['openid'];
  if (!validScopes.includes(scope)) {
    return { authCodeError: 'invalid_scope', target: 'redirectUri' };
  }
  // ここまで追加
  return null;
};

では次にこのバリデーションを使ってリクエストの検証をするコードを controller に追加します。

まず、エラーレスポンス用の ErrorResponse 型を作ります。この型も RFC によって定められています。

なおエラーは application/x-www-form-urlencoded で返す仕様となっています。

// src/controllers/auth_controller.ts

// https://openid.net/specs/openid-connect-core-1_0.html#AuthError
// https://openid-foundation-japan.github.io/rfc6749.ja.html#code-authz-error
type ErrorResponse = {
  error: AuthCodeError;
  error_description?: string;
  error_uri?: string;
};

さて、ここまで作った関数をまとめて実装を追加します。

// src/controllers/auth_controller.ts
export const getAuth = (db: Context, query: ParsedUrlQuery, res: ServerResponse) => {
  try {
    const scope = query.scope;
    const clientId = query.client_id;
    const redirectUri = query.redirect_uri;
    // ここから追加する
    const responseType = query.response_type;
    const queryParams: QueryParams = { scope, responseType, clientId, redirectUri };

    const validated = validate(queryParams);
    if (validated) {
      const responseData: ErrorResponse = { error: validated.authCodeError };
      if (validated.target === 'redirectUri') {
        const response = new URLSearchParams(responseData).toString();
        res.writeHead(302, { 'Content-Type': 'application/x-www-form-urlencoded' });
        res.end(`${redirectUri}?${response}`);
      } else {
        // リソースオーナーは今操作している人である
        // ここのレスポンスは仕様がないためJSONを返す
        res.writeHead(400, { 'Content-Type': 'application/json' });
        res.end(JSON.stringify(responseData));
      }
      return;
    }

これでログインページを開くコントローラーは完成です。なお、認可コードはログイン時に生成するようにします。

無効なパラメーターでアクセスしてみます。

http://localhost:3000/openid-connect/auth?client_id=invalid-client&redirect_uri=http://localhost:4000/oidc/callback&scope=openid&response_type=code

想定通りエラーのレスポンスが返ってきました。次に有効なパラメーターでアクセスします。

http://localhost:3000/openid-connect/auth?client_id=tiny-client&redirect_uri=http://localhost:4000/oidc/callback&scope=openid&response_type=code

有効なパラメーターでアクセスするとログイン画面が開きました。

6.4 ログイン成功時に認可コードを生成しリダイレクトする

次に、POST /login の実装を変更して、ログイン成功時に redirect_uri にリダイレクトするようにします。

6.4.1 認可コード

認可コードとは、OAuth / OpenID Connect においてトークンを取得するために使用する有効期限の短いコードです。トークンの取得にのみ使用されます。

6.4.2 実装する

まずは認可コード用のモデルを作ります。

// src/models/auth_code.ts
export class AuthCode {
  code: string;
  userId: number;
  clientId: string;
  expiresAt: Date;
  usedAt: Date | null = null;
  redirectUri: string;

  constructor(code: string, userId: number, clientId: string, expiresAt: Date, redirectUri: string) {
    this.code = code;
    this.userId = userId;
    this.clientId = clientId;
    this.expiresAt = expiresAt;
    this.redirectUri = redirectUri;
  }

  static build(userId: number, clientId: string, redirectUri: string) {
    const code = Math.random().toString(36).slice(-8);
    const oneMin = 1 * 60 * 1000;
    const expiresAt = new Date(Date.now() + oneMin);
    const authCode = new AuthCode(code, userId, clientId, expiresAt, redirectUri);
    return authCode;
  }

  // 既存レコードがあれば上書きし、なければ新規に保存する
  save(db: AuthCode[]) {
    if (db.some((ac) => ac.code === this.code)) {
      const index = db.findIndex((ac) => ac.code === this.code)
      db[index] = this;
    } else {
      db.push(this);
    }
  }
}

このモデルのデータを使うように Context と index.ts を修正します。

// src/models/context.ts
import { AuthCode } from "./auth_code";
import { User } from "./user";

export type Context = {
  users: User[];
  authCodes: AuthCode[];
};

データの初期化をします。

// src/index.ts
import { AuthCode } from './models/auth_code';
()
const users: User[] = [{ id: 1, email: 'tiny-idp@asmsuechan.com', password: 'p@ssw0rd', clientId: 'tiny-client' }];
// NOTE: 以下を追加する
const authCodes: AuthCode[] = [];
const db = {
  users,
  authCodes,
};

そしてこの認可コードを含めてログイン成功時に redirect_uri にリダイレクトするようにします。

このコードをコントローラーに書きます。

// src/controllers/login_controller.ts
import { AuthCode } from '../models/auth_code';

export const login = (db: Context, query: ParsedUrlQuery, params: URLSearchParams, res: ServerResponse) => {
  const email = params.get('email');
  const password = params.get('password');

  // 以下の4変数を追加
  const redirectUri = query.redirect_uri;
  const scope = query.scope;
  const clientId = query.client_id;
  const issuer = 'http://localhost:3000';

  if (email && password && User.login(db.users, email, password)) {
    // このif文の中身を全て置き換える
    const user = User.findByEmail(db.users, email) as User;
    const authCode = AuthCode.build(user.id, clientId as string, redirectUri as string);
    authCode.save(db.authCodes);
    res.writeHead(302, {
      Location: `${redirectUri}?code=${authCode.code}&iss=${issuer}&scope=${scope}`
    });
    res.end();
  } else {
    res.writeHead(403, { 'Content-Type': 'application/json' });
    res.end(JSON.stringify({ error: 'Unauthorized' }));
  }
};

さて、これで認可エンドポイントの完成です。

6.5 動作確認

tiny-rpを少し変更して認可コードが発行されたか確認できるようにします。

tiny-rpは、トークンエンドポイントをまだ作っていないため動きません。ですのでcallbackを受け取る部分を次のように書き換えます。

// tiny-rp/src/index.ts
app.get("/oidc/callback", async (req, res) => {
  // TODO: トークンを検証するコードは後で追加します
  const code = String(req.query.code);
  res.status(200);
  res.json({ code });
  return;
});

http://localhost:3000/openid-connect/auth?client_id=tiny-client&redirect_uri=http://localhost:4000/oidc/callback&scope=openid&response_type=code ログイン画面よりemail / passwordを入力すると、認可コードが表示できました。

まとめ

この記事ではOpenID Connectの基礎を簡単に説明し、RelyingPartyの実装と、認可エンドポイントの実装をしました。次回はトークンエンドポイントです。

この記事で作成した部分は以下です。

github.com

We're hiring

弊社では認証認可が好きな人もそうでない人もエンジニアを絶賛募集中です!

jobs.m3.com