こんにちは。デジカルチームの末永(asmsuechan)です。この記事は「フルスクラッチして理解するOpenID Connect」の4記事目です。前回はこちら。
今回は全4回中の第4回目です。
- (1) 認可エンドポイント編
- (2) トークンエンドポイント編
- (3) JWT編
- (4) stateとnonce編
13 state の実装
- https://openid-foundation-japan.github.io/rfc6819.ja.html#anchor15
- https://openid-foundation-japan.github.io/rfc6749.ja.html#CSRF
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 まとめ
本記事で作成した部分はこちらです。
本連載では OpenID Connect の ID Provider をフルスクラッチで実装することによって OpenID Connect への理解を深めました。最終的に一番わかりやすい資料はRFCでした。しかし、RFCを読んで分かりやすいと感じるようになるまでに多くの日本語記事を読みました。OpenID Connectは日本語での良質な記事がたくさんあり、0から積み上げて理解していく土俵が揃っているように感じました。全ての先人達に感謝しながらこの記事を執筆しました。
ちなみにこの記事の執筆を契機にOpenID Foundationのメンバーになりました ($50)。
今後の発展として次のようなものが考えられます。
- 一部をフレームワークに置き換えて実装する
- RDB や NoSQL などを使う
- リソースサーバーも実装する
- 別のフローを実装する
- 他のクライアント認証にも対応する
- 署名の none と HS256 への対応
- PKCE の実装
- userinfo のエンドポイントを作る
いろんな遊び方が考えられるので、興味がある方は本記事を基礎として独自の実装をすると楽しめるかもしれません。
16 参考
- OpenID Connect Core 1.0 incorporating errata set 2
- JSON Web Token (JWT)
- JSON Web Key (JWK)
- 基本から理解する JWT と JWT 認証の仕組み
- Spring Security と Keycloak を使ってリソースサーバーを作ってみた
- OAuth2.0 についての話(OpenID Connect との比較を添えて)
- JSON Web Token(JWT)の紹介と Yahoo! JAPAN における JWT の活用
- 🔒NodeJS で秘密鍵で署名して公開鍵で検証する。
- Auth & OpenID Connect 関連仕様まとめ
- OAuth & OpenID Connect の不適切実装まとめ
- OpenID Connect Authorization Code Flow | An Overview
- ID Token and Access Token: What Is the Difference?
- その IDToken の正体はセッショントークン?それともアサーション?
- JWT ハンドブック
- JSON Web Key (JWK)
- 実装すべきもの
- 実践 Keycloak
- 特にこの書籍内で使われているOpenID Connect Playgroundはよく参考にしました
- OpenID Connect入門: 概念からセキュリティまで体系的に押さえる
Wre're hiring!
この記事の大部分は、私が2月上旬に取得した1週間の有給中に趣味で作ったプログラムを元にしています。趣味で作ったプログラムを会社のアウトプットとして発表できる機会に恵まれて非常にありがたい限りです。
弊社ではソフトウェア開発が趣味の人や趣味の開発を仕事に活かしたい人を絶賛募集中です!
カジュアル面談も大歓迎です!私と話してみたいという人がいれば人事の方に「テックブログを見た!」とお伝えください。