- 真野隼記
- 約 6,400 文字
- 1,900 View
目次
夏の自由研究連載2024 の3日目です。
はじめに
TIG 真野です。
夏といえばコード生成というわけで、HTTP API仕様を定義するOpenAPIの security schemes(認証認可を定義するための箇所)で、Bearer/OAuth2/OpenID Connect (OIDC) 認証を設定すると、各コードジェネレータはどういったコード生成をしてくれるかを調べました。
なお、OpenAPIについてはOpenAPI Specification v3.0.3のコーディング規約を公開しましたの記事も参考ください。
この記事で利用した openapi.yaml や生成したサーバサイドのコードは以下のリポジトリにまとめています。
コード生成の対象
- Go言語のみ
- サーバサイドのみ(※クライアントコードは対象外)
比較ツール
Goコードを生成可能な以下のツールを対象にします。今どきはOpen API 3.0.3(3.1.0) を使うと思うので、3系に対応しているツールを選定しています。フューチャー技術ブログではgo-swagger記事がいくつかありますが、go-swaggerは2系にしか対応していないので対象外としています。
- ogen v1.3.0
- oapi-codegen v2.2.0
net/httpモード、strict-serverモード - openapi-generator v7.8.0
gorilla/muxルーターモード
検証の構成やコードについて
構成ですが、クライアントを curl で、JWTトークンをGo製のCLIツールで作成し、OAuth 2.0でいう認可サーバを無くした状態で検証しています(※本来は、公開鍵を /oauth2/jwks や jwks_uri で指定されたURLから取得できるようにすべきですが、ハードコードで省略しています)。
利用するJWTトークンについて
認可サーバやIdPを今回用意しないので、替わりに openssl で秘密鍵・公開鍵を作成します。秘密鍵でJWTを署名し、公開鍵で検証することを想定しているので、非対称鍵系署名アルゴリズムを選定します。
詳しくない領域なので間違っていたらご指摘いただきたいですが、RS256は避けた方が良い というAuthleteさんの話を読み、今回はES512を利用することにします。
# 秘密鍵を生成 |
秘密鍵を利用してJWTトークンを生成します。iss、subなど属性は適当にしています。スコープ(scp)は検証に用いたい値です。
package main |
実行するとJWTトークンが標準出力されます。これをAuthorizationヘッダーに利用します。
go run . |
openapi.yaml
openapi.yaml の security schemes は次のように記載しています。先述の通り、OAuth 2.0での認可サーバ、OIDCでのIdPは利用しない構成のため、authorizationUrlなどの値はすべてダミー値であり、検証には使われません。
components: |
各エンドポイントは次のように定義します。認証なし・Bearer認証・OAuth2.0認証・OIDC認証の4パターンです。
paths: |
openapi.yamlの全体は https://github.com/ma91n/summer2024/blob/main/openapi.yaml を参照ください。
検証項目
この openapi.yaml を元にコードを生成し、次の内容がどのように変化するか確認しました。
- Bearer、OAuth2、OIDC の認証設定によって生成コードがどのように変わるか、変わらないのか
- 生成された認証設定のコードを各フレームワークでどのように実装するか
- OAuth2、OIDC でスコープ(
write:hellosなどの部分)がどう生成コードに影響を与えるか
結果サマリ
【凡例】 ✅️対応あり ✘対応なし
| Name | Bearer | OAuth2 | OIDC | Bearerトークン取得処理 | スコープ対応 | 所感 |
|---|---|---|---|---|---|---|
| ogen | ✅️ | ✅️ | ✘ | コード生成支援あり | コード生成支援あり | 後発だけあり最も洗練度が高い |
| oapi-codegen | ✅️ | ✅️ | ✅️ | 自前実装 | コード生成支援あり | 現時点でも自由度高く実装可能 |
| openapi-generator | ✘ | ✘ | ✘ | 自前実装 | 自前実装 | 認可処理はサポート外なのが惜しい |
今回の検証における、おすすめ度は記載順で、ogen >= oapi-codegen > openapi-generator といった感覚です。別の角度では全く結果が変わることも想定されますので、あくまで判断材料の一部としての利用、認識いただければです。
1. ogen
セットアップです。
# インストール |
そのまま実行すると以下のエラーが表示されます。ogen はサーバサイドコード生成だけど OIDC が未対応でした。
ogen --target api --clean openapi.yaml |
回避としては同一改装に ogen.yaml を用意して次のスキップ設定を追加します(コマンドラインオプションに設定ファイルの指定は不要で、自動で読み込まれるようです)。
generator: |
ogen.yaml で何が指定できるかはドキュメントに記載が見当たらずでした。ご存知の方はX(旧Twitter)でコメントいただけると幸いです。
さて、ogenですが各エンドポイントの実装詳細は ogensample/hello_handler.go を参照いただきたいですが、感覚で言うと生成コードはシンプルでわかりやすく、ハマるポイントは少ないです。
ただ認可側の実装はドキュメントのMisc > Request lifecycle > security-handlersに記載が少しある程度で少し推測が必要でした。
生成コードを読んでいった方が早いかもしれません。ドキュメントと生成コードを見比べていくと、api/SecurityHandler インターフェースを満たす必要があるとわかります。Bearer認証とOAuth2.0認証ごとに呼ばれる関数が異なる作りのようです。さらに同じ認証方式だけど、あるエンドポイントだけで挙動を変えたい場合は、operationName (openapi.yaml で定義した operationId が入っていました)を用いて拡張可能な作りです。親切!
type SecurityHandler interface { bn |
ogenが生成した Bearer や OAuth2 の構造体もシンプル。Tokenはリクエストヘッダの Authorization: Bearer <token> の <token> の値が入っています。OAuth2 側のScopes はopenapi.yaml 側で指定した値が入っています。
type Bearer struct { |
これを満たすように MySecurityHandler を実装します。トークンのチェックはかなり端折った実装になっています。本来は発行者(iss)、アルゴリズム(alg)、用途(aud)、期限(exp)なども確認する必要があるでしょう。
type UserClaim struct { |
上記の2つのハンドラーをmain.goで呼び出して完成です。
package main |
go run . で実行すると、8080 ポートで起動します。
curl で実行して動作確認します。
curl localhost:8080/hello |
あえて失敗させるケースでの挙動を見てみます。
Authorizationヘッダなしで動かすケース |
詳細は割愛しますが、JWTトークンにユーザーIDなどが含まれており、各Handlerアプリ側で利用したい場合も、ctx に詰めて連携可能であり、使いやすインターフェース(フレームワーク)だと感じました。
ogenのサーバサイドコード生成について、まとめると次のような所感です。
- OIDCには対応していない
- Bearer、OAuth2といった種別ごとに一律、ミドルウェアのような形式で認可ロジックを実装する
- 各エンドポイント毎に挙動を変えたい場合は、引数のoperationIdの値を利用して切り替え可能
- Bearer、OAuth2で生成コードの差分はほぼ無いが、スコープフィールドだけが増える
- JWTトークンのパースや検証を自前で実装する
- JWTトークンの値を後続に引き渡したい場合は、
context.Contextを経由する
2. oapi-codegen
ogenより先発だけあって、利用実績も多数なoapi-codegenですが、2024年5月にOrganizationがdeepmapからoapi-codegen に変わったようです。
セットアップです。
# インストール |
oapi-codegen はEcho、Gin、net/http などに沿ったコードを生成できますが、今回は net/http 向けで出力します。また、リクエスト/レスポンスを構造体にマッピングまで行ってくれる、strict-server モードを有効にします。これを踏まえ、先程のコード生成コマンドの引数で渡していたconfig.yaml を以下のように設定しました。
package: api |
各エンドポイントは oapi-codegen が生成した api/gen.go の StrictServerInterfaceインターフェースを実装する必要があります。全体像は hello_handler.go を参照いただきたいですが、ref でオブエジェクト参照すると、ogenと比べ生成されたコードにネストが発生するところが、少しもどかしさを感じます。
type HelloServer struct{} |
認証部分は、ミドルウェアとして実装します。リポジトリ側にexamples実装があるため、それを参考にするとよいでしょう。RAEDMEの記載を見る限り、現状はミドルウェアで実装する必要があるが、将来的にはファーストクラスサポートしていくよとあり、期待です。
サンプル実装を元に main.go を実装します。ミドルウェアですが、nethttpmiddlewareというoapi-codegenが用意してくれたパッケージを利用します。
package main |
ミドルウェアの実体としては次のようなインターフェースを実装します。現時点ではセキュリティスキーマごちゃまぜですので、入力値でスイッチして切り替えます。openapi3filter.AuthenticationInput はopenapi.yaml の解析結果や http.Request も取れるため、やろうと思えばすべて判定できます。
func NewAuthenticator() openapi3filter.AuthenticationFunc { |
サーバを go run . で起動して、動作確認します。
curl localhost:8080/hello |
エラーメッセージはハンドリングがデフォルトのままなので、JSONではなく文字列が返ってきていますが、チェック自体はできていることがわかります。
Authorizationヘッダなしで動かすケース |
oapi-codegenのサーバサイドコード生成について、まとめると次のような所感です。
openapi.yamlの定義上でオブジェクトを$ref参照させると少しコードが冗長に見える(回避策の有無は未調査)- Bearer、OAuth2、OIDCのチェックは現時点ではミドルウェアで実装し、コード生成上のサポートは弱い(将来的に解消する方向とREADMEに記載あり)
- ミドルウェアでは、判定に必要な情報はすべて取得できるため、該当のリクエストがBearer、OAuth2、OIDCのどれでチェックすべきか、operationIdを用いた各エンドポイント固有の挙動を追加するなど柔軟に開発できる自由度がある
- 試していないが、JWTトークンを解析した結果は
http.Requestを取得できるためRequest.WithContext()を用いたcontext.Context経由で各エンドポイント側に渡すことが可能だと考えられる - JWTトークンのパースどころか、リクエストヘッダから取得するところまで自前開発が必要(とはいえ、大したコード量にはならない)
- 様々な出力モードがあるため、検索結果が別の設定モードの場合があり、見極め力が必要な場合がある
3. openapi-generator
最も有名なコード生成ツールである openapi-generator を試します。様々な言語に対応していますが、記事の趣旨からGo言語かつサーバサイドに絞って生成します。
セットアップです。
# インストール |
各エンドポイントのコードは生成されたopenapi/api_ping_service.go のTODOを埋めて実装します。DO NOT EDITを手動で消しつつ、 .openapi-generator-ignore に上記のパスを追加することで、上書きされることを防ぐ必要があります。
type PingAPIService struct { |
openapi-generator のGoサーバサイド生成ですが、セキュリティスキーマには未対応 です。generators/go-server.md にもその旨が書かれています。コード生成上の支援は現時点では受けられません。
そのため、認証処理はミドルウェアで個別実装する必要があります。デフォルトでは GitHub.com/gorilla/mux が利用される(chiに切り替えも可能)ので、muxのミドルウェアを実装します。
ミドルウェアとして、そのリクエストが認証なし・Bearer・OAuth2.0・OIDCかどうかは自分で判定する必要があります。また、該当のリクエストがどのスコープを要求するかも自分でマッピングを準備する必要があります。
func Authentication(next http.Handler) http.Handler { |
マッピング情報を oepnapi.yaml とダブルメンテすることは実運用上、耐えられないチームが多いと思いますのでそのままでは利用できないという判断を下すことが多いかなと思います。そのため、Yusuke ItoさんのZennブック【Go言語】OpenAPI Generatorを使いこなすスキーマ駆動開発 にある通り、テンプレートがmustacheで書かれており、これを拡張して利用するといった取り組みが必要となるかと思います。
openapi-generator のサーバサイドコード生成について、まとめると次のような所感です。
- 記事には書いていなかったが、
.openapi-generator、.openapi-generator-ignore、openapitools.json、README.mdなどGoのコード以外にもファイルが生成して初見は少し驚く - 各エンドポイントの実装は迷うことが少ないが、ImplResponseのボディは
interface{}であるため、型がふわっとしてしまって残念に感じた(回避方法の有無は未調査) - 認可周りのコード生成上の支援が無く、使いこなすにはテンプレートをカスタマイズする勢いが必要そう
- Yusuke ItoさんのZennブック[【Go言語】OpenAPI Generatorを使いこなすスキーマ駆動開発]によれば、カスタマイズしたテンプレート実行には、Java環境(Mavenなど)が必要で、メンバーのスキルセット次第では障壁がある(FAT JAR提供とかあったらすいません)
- 調査すると、他の言語(Swiftなど)の結果が出てくるので、検索ワード力が必要かもしれない
さいごに
OpenAPI 3系かつGoのサーバサイドコード生成に対応した ogen・oapi-codegen・openapi-generatorについて、Bearer・OAuth2.0・OIDC認可でどのようにコード生成が対応しているか試しました。現時点では ogen が後発だけあって一番垢抜けていて、oapi-codegenも十分に扱いやすい。openapi-generatorは玄人向けだなと感じました。
コード生成上はサーバサイドに限ると、Bearer・OAuth2.0・OIDCで変化はほぼ無いため(大半はミドルウェア的に実装するだけ)であるため、サーバサイドの実装視点では openapi.yaml で細かく定義する意味はあまり無い(もちろんWeb APIの利用者視点では有意義な手がかりになるでしょうが)という結果でした。
近い将来では、これらコードジェネレータの領域は、ChatGPTとかclaude.aiに openapi.yaml を入力させてプロンプトエンジニアリングでコード生成させる方向でも広がっていくと良いかなと思っています。コード生成コマンド時のオプションや、設定ファイルなどで行う微調整的な機能は、生成AI側で行ってくれる未来…、来ると良いなぁ。