エムスリーテックブログ

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

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

こんにちは。デジカルチームの末永(asmsuechan)です。この記事は「フルスクラッチして理解するOpenID Connect」の2記事目です。前回はこちら。

www.m3tech.blog

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

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

7. トークンエンドポイントの実装(POST /openid-connect/token)

次にトークンエンドポイントを作ります。OIDC ではアクセストークンと ID トークンの 2 つのトークンを返します。

トークンエンドポイントはリクエストで次のパラメーターを受け取ります。なお、content-type は application/x-www-form-urlencoded と定められています。

パラメーター 説明
client_id ここでは固定値の tiny-client tiny-client
redirect_uri RP の redirect_uri http://localhost:4000/oidc/callback
code 認可コードを表すランダムな文字列 SplxlOBeZQQYbYS6WxSbIA
grant_type ここでは認可コードを使うので authorization_code 固定です authorization_code

レスポンスとして次を返します。content-type は application/json です。

パラメーター 説明
access_token 発行されたアクセストークン。形式に決まりはなく、ランダムな文字列でも JWT でもよい(RFC9068)。 SlAV32hkKG
token_type Bearer でなければならない。Case insensitive. Bearer
expires_in アクセストークンの有効期限 86400
id_token 発行された ID トークン。JWT が使われる eyJhbGciOiJSUzI1NiIsImtp
ZCI6IjFlOWdkazcifQ.ewog(略)
scope 発行されたトークンの scope (optional) openid
refresh_token 新しいトークンを発行するためのトークン (optional), tiny-idpでは実装しない AikqQsp3be

また、レスポンスのヘッダーにも決まりがあります。次のヘッダーの設定は必須です。OAuth2.0 では Pragma: no-cache も必須であるようです。

ヘッダー名
Cache-Control no-store

次はレスポンスの仕様です。

7.1 アクセストークン

アクセストークンはリソースサーバーに対してリクエストする時に使うトークンで、リソースサーバーに対する操作を認可するためのものです。外部サイト連携で「許可する項目: メールアドレスと名前」のような表示がでてくることがありますが、この認可に紐づいたトークンとなります。なおアクセストークンは OAuth2.0 の仕様です。

リソースサーバーで使う時には発行者 (ここでは IdP) にトークンの有効性を尋ねるか、JWT などを使っていてトークンが検証可能であればリソースサーバー内で検証します。

IdP にはアクセストークンと共にスコープと有効期限を一緒に保存します。

この記事ではあくまで OpenID Connect の ID Provider を作ることをメインとしているので、リソースサーバーまで作ることはしません。

Facebook連携の例

アクセストークンは連携先システムのリソースを扱いたい時に使います。例えば自前のブログシステムを作ったとします。このブログシステムに投稿した時、Facebook にも同じ内容を投稿したいとします。

  • ブログ投稿と同時にFacebookにも同じ内容を投稿したい
  • Facebook連携が必要
    • 投稿ができるスコープが設定されている必要がある
  • ブログに投稿するとアクセストークンを含めた同時投稿リクエストをFacebookのリソースサーバーに送信する

7.2 ID トークン

ID トークンは OpenID Connect の認証の結果出力されたトークンです。ID トークンを認証された証として、連携先の Web アプリケーションに送信することでその Web アプリケーションでの認証とできます。トークン自体に、正しい ID Provider から発行されたものであることを証明する機能を付ける必要があるため JWT が利用されます。

セッションの管理は ID トークンの役割ではないため IdP は ID トークンを保持していなくても問題ありません。

Facebook連携の例

自分の作った Web アプリケーションに Facebook ログインを実装する例で考えてみます。あくまで Facebook は例で、実装の詳細には触れません。ここで自分の Web アプリケーションが RelyingParty で、Facebook が ID Provider となります。

  • ユーザーがFacebookにID/Passwordを入力してログインする
  • コールバックとしてWebアプリケーションに認可コードが送られてくる
  • 認可コードを使ってIDトークンを取得する
  • RelyingPartyでIDトークンの検証をする
  • 自分のWebアプリケーションで登録/ログイン処理が走る
    • データベースの users テーブルに ID トークンに含まれる sub などの一意な値と一緒に保存
    • 既にユーザーが存在すれば sub を使ってログインしたユーザーを特定

これで Facebook ログインが実装できます。プロフィール画像やメールアドレス、表示名などを Facebook から受け取って Web アプリケーションでも保存したい場合はアクセストークンを使って Facebook のリソースサーバーにアクセスします。

7.3 IDトークンを返す部分を作る

上記のリクエストパラメーターとレスポンスの仕様に基づいて、トークンを返すコントローラーを作ります。アクセストークンと ID トークンはダミーを返します。

src/controllers/token_controller.tsを作ります。レスポンスの型を上記のテーブル通りに作ります。なおoptionalのscopeとrefresh_tokenは省きます。リクエストの型はパラメーターの検証時に作ります。

// src/controllers/token_controller.ts
// https://openid.net/specs/openid-connect-core-1_0.html#TokenResponse
// https://openid-foundation-japan.github.io/rfc6749.ja.html#anchor25
type ResponseData = {
  id_token: string;
  access_token: string;
  token_type: string;
  expires_in: number;
};

ではコントローラー本体を作ります。返すトークンはアクセストークンとIDトークン共にダミーとします。

// src/controllers/token_controller.ts
import { Context } from "../models/context";
import { ServerResponse } from "http";

(型略)

// > 空の値で送信されたパラメーターは省略されたものとして扱われなければならない (MUST).
// > 認可サーバーは未知のリクエストパラメーターは無視しなければならない (MUST).
// > リクエストおよびレスポンスパラメーターは重複を許さない (MUST NOT).
// > https://openid-foundation-japan.github.io/rfc6749.ja.html#anchor23
export const postToken = (db: Context, params: URLSearchParams, res: ServerResponse) => {
  const clientId = params.get('client_id');
  const code = params.get('code');
  // NOTE: 未使用の認可コードを見つけてくる
  const authCode = db.authCodes.find((ac) => {
    return ac.code === code && ac.clientId === clientId && ac.expiresAt > new Date();
  });
  // NOTE: 一度使用した認可コードには使用済み日時を入れる
  // 後ほど使用済みであればエラーにするようバリデーションを追加する
  authCode!.usedAt = new Date();
  authCode!.save(db.authCodes);

  res.writeHead(200, {
    'Content-Type': 'application/json',
    'Cache-Control': 'no-store',
    Pragma: 'no-cache'
  });
  const data: ResponseData = {
    id_token: 'dummy-id-token',
    access_token: 'dummy-access-token',
    token_type: 'Bearer',
    expires_in: 86400
  };
  res.end(JSON.stringify(data));
};

作ったコントローラーをindex.tsで使うようにします。エンドポイントは /openid-connect/token です。

// src/index.ts
import { postToken } from './controllers/token_controller';
()
  } else if (req.url?.split('?')[0] === '/openid-connect/token' && req.method === 'POST') {
    let body = '';
    req.on('data', (chunk) => {
      body += chunk;
    });
    req.on('end', () => {
      const params = new URLSearchParams(body);
      postToken(db, params, res);
    });

そしてブラウザで動作確認をするために、tiny-rpを編集します。callback先のエンドポイントで、認可コードを使ってトークンリクエストをtiny-rpに送信するようにします。

// tiny-rp/src/index.ts
()
app.get("/oidc/callback", async (req, res) => {
  // TODO: トークンを検証するコードは後で追加します
  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();
    res.json({ tokenSet });
    return;
  } catch (error) {
    console.error("Access Token Error: ", error);
    res.status(500);
    res.json({ error: "Access Token Error" });
    return;
  }
});

ここまでできたらブラウザで動作確認を行えます。

tiny-rpとtiny-idpの双方で npm run build && node lib/index.ts を実行し、アプリケーションを再起動します。

そしてlocalhost:4000にアクセスし、ID/Passwordを入力すると次のようにダミーのトークンが表示されるはずです。

7.4 アクセストークンを返す

上記ではダミーを返しましたが、ここでアクセストークン部分を実装していきます。tiny-idpでは、アクセストークンの本体はランダムな予想されない文字列とし、その有効期限は24時間とします。

アクセストークンは後で実装するイントロスペクトエンドポイントで有効かどうかをIdPで検証するのでデータとして保存しておきます。

// src/models/access_token.ts
const ONE_DAY = 60 * 60 * 24; // 86400
export class AccessToken {
  token: string;
  expiresAt: number;
  userId: number;

  constructor(token: string, expiresAt: number, userId: number) {
    this.token = token;
    this.expiresAt = expiresAt;
    this.userId = userId;
  }

  static build(userId: number) {
    const token = Math.random().toString(36).slice(-8);
    const expiresIn = ONE_DAY * 1000;
    return new AccessToken(token, new Date().getTime() + expiresIn, userId);
  }

  save(db: AccessToken[]) {
    if (db.some((at) => at.userId === this.userId)) {
      const index = db.findIndex((at) => at.userId === this.userId);
      db[index] = this;
    } else {
      db.push(this);
    }
  }

  isValid() {
    return this.expiresAt > Date.now();
  }
}

モデルを作ったので Context 型と index.ts のデータ初期化をしておきます。

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

export type Context = {
  users: User[];
  authCodes: AuthCode[];
  accessTokens: AccessToken[]; // 追加
};

index.tsのデータ側にも追加しておきます。

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

ここまでできたら、token_controller.tsでアクセストークンを生成してレスポンスに含めましょう。

// src/controllers/token_controller.ts
()
  const accessToken = AccessToken.build(authCode!.userId); // 追加
  accessToken.save(db.accessTokens); // 追加

  res.writeHead(200, {
    'Content-Type': 'application/json',
    'Cache-Control': 'no-store',
    Pragma: 'no-cache'
  });
  const data: ResponseData = {
    id_token: 'dummy-id-token',
    access_token: accessToken.token, // 追加
    token_type: 'Bearer',
    expires_in: 86400
  };

これでアクセストークンの生成はできたので、検証してみます。npm run build && node lib/index.js して localhost:4000 にアクセスしましょう。

ログインすると、ダミーではないアクセストークンが返ってきました。

7.5 パラメーターの検証

認可リクエストと同じように、トークンリクエストでもリクエストパラメーターの検証をします。検証の仕様はOAuthとOpenID Connect双方のRFCで定められています。

まずはリクエストの型を作ります。リクエストパラメーターについてはこの章の上の方にあるテーブルに記載しています。

// src/controllers/token_controller.ts
// https://openid.net/specs/openid-connect-core-1_0.html#TokenRequest
// https://openid-foundation-japan.github.io/rfc6749.ja.html#token-req
type RequestParams = {
  grantType: string | null;
  code: string | null;
  redirectUri: string | null;
  clientId: string | null;
};

検証エラー時の型を追加します。エラーコードとエラーレスポンスの形式は認可リクエストと同じくRFCで定められているのでそれに従います。

// src/controllers/token_controller.ts
// https://openid.net/specs/openid-connect-core-1_0.html#TokenErrorResponse
// https://openid-foundation-japan.github.io/rfc6749.ja.html#token-errors
type TokenError =
  | 'invalid_request'
  | 'invalid_client'
  | 'invalid_grant'
  | 'unauthorized_client'
  | 'unsupported_grant_type'
  | 'invalid_scope';
type ErrorResponse = {
  error: TokenError;
  error_description?: string;
  error_uri?: string;
};

次にtoken_controller.tsに実際の処理となるvalidate()関数を追加します。次の2点を確認します。

  • 必須パラメーターの存在チェック
  • grant_typeが認可コードであること
// src/controllers/token_controller.ts
// https://openid.net/specs/openid-connect-core-1_0.html#TokenRequestValidation
// https://openid-foundation-japan.github.io/rfc6749.ja.html#token-errors
// 実装しない仕様
// * Authorization リクエストヘッダーでの認証
const validate = (requestParams: RequestParams): TokenError | null => {
  if (!requestParams.clientId || !requestParams.code || !requestParams.grantType || !requestParams.redirectUri) {
    return 'invalid_request';
  }
  if (requestParams.grantType !== 'authorization_code') {
    return 'unsupported_grant_type';
  }

  return null;
};

コントローラーでvalidate()関数を使うように変更します。問題があった場合はエラーを返します。

export const postToken = (db: Context, params: URLSearchParams, res: ServerResponse) => {
  const clientId = params.get('client_id');
  const code = params.get('code');
  // NOTE: ここから追加
  const grantType = params.get('grant_type');
  const redirectUri = params.get('redirect_uri');
  const requestParams: RequestParams = { grantType, code, redirectUri, clientId };

  const validated = validate(requestParams);
  if (validated) {
    res.writeHead(400, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store', Pragma: 'no-cache' });
    const response: ErrorResponse = { error: validated };
    res.end(JSON.stringify(response));
    return;
  }
  // NOTE: ここまで追加

では npm run build && node lib/index.js で再起動して検証エラーが出るか試してみます。

ブラウザ上からトークンリクエストのパラメーターを変えるのは難しいので、tiny-rpのリクエスト部分を少し変更します。

// tiny-rp/src/index.ts
          grant_type: "invalid_grant", // authorization_codeから変更

tiny-rpも再起動して、localhost:4000より動作を確認します。

無事実装したエラーが出ました。

7.6 認可コードの検証

トークンエンドポイントには認可エンドポイントで生成した認可コードが送られてきます。まずはこの認可コードが正しい値であるかどうかを検証します。ここでは

  • 認可コードが存在するか
  • 認可コードが未使用であるか
  • 認可リクエストで指定されたredirect_uriとトークンリクエストで指定されたredirect_uriが等しいか

を確認します。

validate()関数を変更します。

// src/controllers/token_controller.ts
import { AuthCode } from '../models/auth_code'; // 追加

const validate = (requestParams: RequestParams, authCode?: AuthCode): TokenError | null => { // authCodeを追加
  ()
  // https://openid-foundation-japan.github.io/rfc6749.ja.html#code-authz-resp
  if (!authCode || authCode.usedAt || authCode.redirectUri !== requestParams.redirectUri) {
    return 'invalid_grant';
  }

validate()関数に引数を追加したので、使う側でauthCodeを入れるようにします。変数の場所などは適宜調整してください。

// src/controllers/token_controller.ts
  const validated = validate(requestParams, authCode); // authCodeを追加

これも動作確認してみます。先ほどと同じようにtiny-rp側でコードを適当な値に変えます。

// tiny-rp/src/index.ts
          code: "invalid_code",

npm run build && node lib/index.js でtiny-rpとtiny-idpを再起動してlocalhost:4000よりログインしてみます。

想定通り、invalid_grantが出ました。

7.7 クライアント認証

tiny-idpのトークンエンドポイントではクライアント認証を実装します。クライアント認証をするにはリクエストボディに client_id と client_secret を含めます。

o require client authentication for confidential clients or for any
client that was issued client credentials (or with other
authentication requirements),
https://datatracker.ietf.org/doc/html/rfc6749#section-4.1.3

クライアント認証を実現するため、client_idとclient_secretを含むClientというデータを作ります。ここではclient_idとclient_secretは固定値とします。

// src/models/client.ts
export class Client {
  clientId: string;
  clientSecret: string;

  constructor(clientId: string, clientSecret: string) {
    this.clientId = clientId;
    this.clientSecret = clientSecret;
  }
}

モデルを追加したのでContext型にも追加します。

// src/models/context.ts
import { Client } from './client'; // 追加
import { AccessToken } from './access_token';
import { AuthCode } from './auth_code';
import { User } from './user';

export type Context = {
  users: User[];
  authCodes: AuthCode[];
  accessTokens: AccessToken[];
  clients: Client[]; // 追加
};

Clientのデータをdb変数に追加しておきます。固定値です。

// src/index.ts
import { Client } from './models/client';

const clients: Client[] = [{ clientId: 'tiny-client', clientSecret: 'c1!3n753cr37' }]; // 追加
const db = {
  users,
  authCodes,
  accessTokens,
  clients // 追加
};

クライアント認証をするコードをvalidate()関数内に追加します。リクエストに含まれるclient_secretとデータのclient_secretが異なっていればエラーを返します。

// src/controllers/token_controller.ts
import { Client } from '../models/client'; // 追加

// https://openid.net/specs/openid-connect-core-1_0.html#TokenRequest
// https://openid-foundation-japan.github.io/rfc6749.ja.html#token-req
type RequestParams = {
  grantType: string | null;
  code: string | null;
  redirectUri: string | null;
  clientId: string | null;
  clientSecret: string | null; // 追加
};
()
  // postToken()関数内に以下を追加
  const clientSecret = params.get('client_secret');
  const requestParams: RequestParams = { grantType, code, redirectUri, clientId, clientSecret };

  const client = db.clients.find((c) => c.clientId === clientId);
  const validated = validate(requestParams, authCode, client); // clientを追加
()

const validate = (requestParams: RequestParams, authCode?: AuthCode, client?: Client): TokenError | null => { // clientを追加
  ()
  // 以下の条件文を追加
  if (!client || client.clientSecret !== requestParams.clientSecret) {
    return 'invalid_client';
  }

client_secretはtiny-rpだとまだ渡していないので、渡すように変更します。まずは検証エラーになることを確認したいので、無効なclient_secretを入れてみます。

// tiny-rp/src/index.ts
        body: new URLSearchParams({
          code,
          redirect_uri,
          scope,
          grant_type: "authorization_code",
          client_id: "tiny-client",
          client_secret: "invalid_secret", // 追加
        }),

この状態で保存し、tiny-rpとtiny-idp共に npm run build && node lib/index.js で再起動し、localhost:4000から動作確認します。

無事にinvalid_clientエラーがでました。では次にclient_secretを正しく設定し、クライアント認証が通ることを確認します。

// tiny-rp/src/index.ts
        body: new URLSearchParams({
          code,
          redirect_uri,
          scope,
          grant_type: "authorization_code",
          client_id: "tiny-client",
          client_secret: "c1!3n753cr37", // 変更
        }),

トークンが取得できることを確認できました。

8 イントロスペクションエンドポイントを作る(POST /openid-connect/introspect)

トークンエンドポイントを実装しました。これは OAuth 2.0 の仕様ですが、アクセストークンもレスポンスに含まれます。ここではこのアクセストークンが有効なものかどうかを確認するためにイントロスペクションエンドポイントを実装します。

次のように introspect_controller.ts を作成します。

// src/controllers/introspect_controller.ts
// https://datatracker.ietf.org/doc/html/rfc7662
import { ServerResponse } from 'http';
import { Context } from '../models/context';

export const postIntrospect = (db: Context, params: URLSearchParams, res: ServerResponse) => {
  const accessToken = params.get('token');
  const foundToken = db.accessTokens.find((ac) => ac.token === accessToken);
  if (!foundToken || foundToken.expiresAt < new Date().getTime()) {
    res.writeHead(401, { 'Content-Type': 'application/json' });
    const response = { active: false };
    res.end(JSON.stringify(response));
    return;
  }
  res.writeHead(200, { 'Content-Type': 'application/json' });
  const response = { active: true };
  res.end(JSON.stringify(response));
};

やっていることは単純で、トークンを DB から探して、有効期限中であれば active: true を返し、期限切れであれば active: false を返します。

RFC によるとリクエストは application/x-www-form-urlencoded で POST メソッドです。レスポンスは application/json で active パラメーターだけが必須です。

このリクエストを受け取る口を index.ts に作れば完成です。

// src/index.ts
import { postIntrospect } from './controllers/introspect_controller';
()
  } else if (req.url?.split('?')[0] === '/openid-connect/introspect' && req.method === 'POST') {
    let body = '';
    req.on('data', (chunk) => {
      body += chunk;
    });
    req.on('end', () => {
      const params = new URLSearchParams(body);
      postIntrospect(db, params, res);
    });

これはcurlで動作確認しましょう。npm run build && node lib/index.js でtiny-idpを再起動します。一度localhost:4000から再ログインしてアクセストークンを取得します。

取得したアクセストークンをイントロスペクトエンドポイントに送るとactive: trueが返ってきました。

~/src/asmsuechan/tiny-idp-2 > curl localhost:3000/openid-connect/introspect -X POST -d 'token=8unyqw8h'
{"active":true}

まとめ

トークンエンドポイントとイントロスペクトエンドポイントを実装しました。また、パラメーターの検証も行いました。次はJWTの実装をします。

この記事で作成したコードはGitHubに上げています。

github.com

We're hiring

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

jobs.m3.com