エムスリーテックブログ

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

フルスクラッチして理解するOpenID Connect (4) stateとnonce編

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

www.m3tech.blog

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

13 state の実装

state は OAuth 由来の仕様です。つまりアクセストークンの保護を目的とした仕様です。RFC に記載の通り、CSRF 攻撃を防ぐためのものです。

実装では、RelyingPartyから送信する認可リクエストのパラメーターに state を付与します。そしてリダイレクトのパラメーターにも state を付与します。

パラメーター 説明
state 予測されないランダムな文字列 6b2748a536b7819e02e441a365fab28d

stateはRelyingPartyで作成した後、sessionに保存します。RelyingPartyではcallbackを受けた時sessionに保存されているstateとcallbackのパラメーターに含まれるstateを比較して同じであることを確認します。なお、実装を簡略化するためにstate (とnonce) は必須にしています。

state の実装のため、全体的に修正をしていきます。

まずは tiny-idp 側に変更を加えます。クエリに渡すパラメーターが増えるので QueryParams 型に state を追加します。

// src/controllers/auth_controller.ts
type QueryParams = {
  scope: string | string[] | undefined;
  responseType: string | string[] | undefined;
  clientId: string | string[] | undefined;
  redirectUri: string | string[] | undefined;
  state: string | string[] | undefined; // 追加
};

auth_controller.ts の getAuth 中に以下を追加します。

// src/controllers/auth_controller.ts
    const state = query.state;
    const queryParams: QueryParams = { scope, responseType, clientId, redirectUri, state };
()
template = template.replace(/{state}/g, String(query.state));

login.html の form の action も変更します。

// src/views/login.html
<form
  action="http://localhost:3000/login?client_id={client_id}&redirect_uri={redirect_uri}&scope={scope}&state={state}"
  method="post"
></form>

そして auth_controller.ts の validate()関数も state を必ず持つよう修正します。

// src/controllers/auth_controller.ts
const state = query.state;
if (
  !redirectUri ||
  Array.isArray(redirectUri) ||
  !validRedirectUris.includes(redirectUri) ||
  !clientId ||
  Array.isArray(clientId) ||
  !validClientIds.includes(clientId) ||
  !state || // 追加
  Array.isArray(state) // 追加
) {
  return { authCodeError: "invalid_request", target: "resourceOwner" };
}

login_controller.ts の postLogin()関数も修正します。

// src/controllers/login_controller.ts
()
    const state = query.state; // 追加
()
    res.writeHead(302, {
      Location: `${redirectUri}?code=${authCode.code}&iss=${issuer}&scope=${scope}&state=${state}`
    }); // 変更

次に tiny-rp を変更します。express-session を追加します。

$ npm i express-session
$ npm i --save-dev @types/express-session

コードの修正をしていきます。まずは express-session の設定をしてセッションを扱えるようにします。

// src/index.ts
import session from "express-session";
app.use(
  session({
    secret: "tiny-rp-secret",
    cookie: {},
  })
);
// https://www.typescriptlang.org/docs/handbook/declaration-merging.html#module-augmentation
declare module "express-session" {
  interface SessionData {
    state: string;
  }
}

GET /で ランダムな値となる state を 生成し session に格納します。認可リクエストにもstateを含めるため、authorizationUrlにもstateを追加します。

// src/index.ts
app.get('/', async (req, res) => {
  // https://openid-foundation-japan.github.io/rfc6749.ja.html#CSRF
  const state = crypto.randomBytes(16).toString('hex');
  req.session.state = state;
  const authorizationUri = client.authorizationUrl({
    redirect_uri: "http://localhost:4000/oidc/callback",
    scope: "openid",
    state, // 追加
  });

そして最後に GET /oidc/callback で state の検証をします。state が一致しなければ 400 エラーを返します。

// src/index.ts
app.get('/oidc/callback', async (req, res) => {
  if (req.session.state !== req.query.state) {
    res.status(400);
    res.json({ error: "invalid state" });
    return;
  }

npm run build && node lib/index.js でアプリケーションを再起動しログインしてみると、トークンが返ってきます。これでstateの実装は完了です。

14 nonce の実装

nonce は OpenID Connect 由来の仕様です。つまりIDトークンの保護を目的とした仕様です。RFC に記載の通り、リプレイ攻撃を防ぐためのものです。

実装は基本的に state とほぼ同じ流れで進めます。

RelyingPartyは予測されない文字列でnonceを生成し、sessionに保存します。認可リクエストにnonceを含めると、トークンリクエストで返すIDトークン中にnonceを入れるようにします。

RelyingPartyでは返ってきたIDトークンの中にnonceが含まれているので、その値とsession中の値をRelyingPartyで比較して同じであることを確認します。

まずは、state と同様に認可エンドポイントで nonce のパラメーターも受け取るので QueryParams 型に nonce を追加します。

// src/controllers/auth_controller.ts
type QueryParams = {
  scope: string | string[] | undefined;
  responseType: string | string[] | undefined;
  clientId: string | string[] | undefined;
  redirectUri: string | string[] | undefined;
  state: string | string[] | undefined;
  nonce: string | string[] | undefined;
};

この型を使うよう getAuth()関数も変更します。

// src/controllers/auth_controller.ts
    const nonce = query.nonce;
    const queryParams: QueryParams = { scope, responseType, clientId, redirectUri, state, nonce };
()
    template = template.replace(/{nonce}/g, String(nonce));

nonce をログインの form のクエリパラメーターに追加します。

// src/views/login.html
<form
  action="http://localhost:3000/login?client_id={client_id}&redirect_uri={redirect_uri}&scope={scope}&state={state}&nonce={nonce}"
  method="post"
></form>

nonce も必須パラメーターとしたいので、validate()関数に条件を追加します。

// src/controllers/auth_controller.ts
const nonce = query.nonce;
if (
  !redirectUri ||
  Array.isArray(redirectUri) ||
  !validRedirectUris.includes(redirectUri) ||
  !clientId ||
  Array.isArray(clientId) ||
  !validClientIds.includes(clientId) ||
  !state ||
  Array.isArray(state) ||
  !nonce ||
  Array.isArray(nonce)
) {
  return { authCodeError: "invalid_request", target: "resourceOwner" };
}

次に、AuthCodeを変更してnonceも保存するようにします。

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

  constructor(
    code: string,
    userId: number,
    clientId: string,
    expiresAt: Date,
    redirectUri: string,
    nonce: string | null = null // 追加
  ) {
    this.code = code;
    this.userId = userId;
    this.clientId = clientId;
    this.expiresAt = expiresAt;
    this.redirectUri = redirectUri;
    this.nonce = nonce; // 追加
  }

  static build(userId: number, clientId: string, redirectUri: string, nonce: string | null = null) { // 変更
    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, nonce); // 変更
    return authCode;
  }

この変更に従って、AuthCodeの生成時にnonceを保存するようにします。

// src/controllers/login_controller.ts
  const nonce = query.nonce; // 追加

  if (email && password && User.login(db.users, email, password)) {
    const user = User.findByEmail(db.users, email) as User;
    const authCode = AuthCode.build(user.id, clientId as string, redirectUri as string, (nonce as string) || null); // 変更
    authCode.save(db.authCodes);

さて、ここまでで認可エンドポイントまでの流れに nonce を組み込むことができました。

次に、ID トークンの JWT 中に nonce を含めるよう変更します。JWT のペイロード中に nonce を追加します。

// src/services/jwt_service.ts
type JwtPayload = {
  iss: string;
  sub: string;
  aud: string;
  exp: number;
  iat: number;
  nonce: string; // 追加
}

()
  public generate(iss: string, aud: string, nonce: string, expDuration: number = this.ONE_MIN): string { // 変更
    const encodedHeader = this.base64urlEncode(JSON.stringify(this.buildHeader()));
    const encodedPayload = this.base64urlEncode(JSON.stringify(this.buildPayload(iss, aud, nonce, expDuration))); // 変更
    const signTarget = `${encodedHeader}.${encodedPayload}`;
    const signature = this.sign(signTarget);
    return `${signTarget}.${this.base64urlEncode(signature)}`
  }

()
  private buildPayload(iss: string, aud: string, nonce: string, expDuration: number = this.ONE_MIN): JwtPayload { // 変更
    const sub = Math.random().toString(16).slice(2);
    const iat = Math.floor(Date.now() / 1000);
    const exp = iat + expDuration;
    return { iss, sub, aud, exp, iat, nonce }; // 変更
  }

そして最後に nonce を JwtService.generate()関数に渡してやれば IdP 側は完成です。

// src/controllers/token_controller.ts
  const jwt = jwtService.generate('http://localhost:3000', 'tiny-client', authCode!.nonce!);

では RelyingParty 側を変更します。

SessionData の interface に nonce を追加して、ランダムな文字列を session と認可 URL に追加します。

// tiny-rp/src/index.ts
declare module "express-session" {
  interface SessionData {
    state: string;
    nonce: string; // 追加
  }
}
()
const nonce = crypto.randomBytes(16).toString("hex");
req.session.nonce = nonce;
const authorizationUri = client.authorizationUrl({
  redirect_uri: "http://localhost:4000/oidc/callback",
  scope: "openid",
  state,
  nonce,
});

これで ID トークンの JWT のペイロードに nonce が入って返ってくるようになりました。

最後に、この nonce を RelyingParty で検証します。

// tiny-idp/src/index.ts

// 追加
const decodeToken = (token: string) => {
  const [encodedHeader, encodedPayload, _encodedSignature] = token.split(".");
  const header = JSON.parse(base64urlDecode(encodedHeader));
  const payload = JSON.parse(base64urlDecode(encodedPayload));
  return { header, payload };
};

()
const idToken = tokenSet.id_token;
if (decodeToken(idToken).payload.nonce !== req.session.nonce) {
  res.status(400);
  res.json({ error: "invalid nonce" });
  return;
}

これでnonceの実装も完了しました。

15 まとめ

本記事で作成した部分はこちらです。

github.com

本連載では OpenID Connect の ID Provider をフルスクラッチで実装することによって OpenID Connect への理解を深めました。最終的に一番わかりやすい資料はRFCでした。しかし、RFCを読んで分かりやすいと感じるようになるまでに多くの日本語記事を読みました。OpenID Connectは日本語での良質な記事がたくさんあり、0から積み上げて理解していく土俵が揃っているように感じました。全ての先人達に感謝しながらこの記事を執筆しました。

ちなみにこの記事の執筆を契機にOpenID Foundationのメンバーになりました ($50)。

今後の発展として次のようなものが考えられます。

  • 一部をフレームワークに置き換えて実装する
  • RDB や NoSQL などを使う
  • リソースサーバーも実装する
  • 別のフローを実装する
  • 他のクライアント認証にも対応する
  • 署名の none と HS256 への対応
  • PKCE の実装
  • userinfo のエンドポイントを作る

いろんな遊び方が考えられるので、興味がある方は本記事を基礎として独自の実装をすると楽しめるかもしれません。

www.m3tech.blog

www.m3tech.blog

www.m3tech.blog

16 参考

Wre're hiring!

この記事の大部分は、私が2月上旬に取得した1週間の有給中に趣味で作ったプログラムを元にしています。趣味で作ったプログラムを会社のアウトプットとして発表できる機会に恵まれて非常にありがたい限りです。

弊社ではソフトウェア開発が趣味の人や趣味の開発を仕事に活かしたい人を絶賛募集中です!

カジュアル面談も大歓迎です!私と話してみたいという人がいれば人事の方に「テックブログを見た!」とお伝えください。

jobs.m3.com