Rack で利用できる汎用認証ミドルウェアである OmniAuth を活用した OAuth 2 認可サーバー & OIDC ID プロバイダ sorah/himari を結構前に作った。今回 MCP サーバーの認証を作る機会があり Himari からアクセストークンを出すことにしたため、MCP で必要な機能を追加で Himari へ実装したのでそれを紹介する。
Himari とは
OmniAuth から受け取った情報を元にセッションを作り、OAuth クライアントへアクセストークンや ID トークンを発行する。OmniAuth での認証時、下流へのトークン発行時に Ruby コードで claim カスタマイズや認可を行えるシンプルな Authorization Server になっている。基本的には Dex みたいなものだが、サーバーレスで動かしたかったのと、GitHub のような OIDC プロバイダではないサービスでユーザーにはログインさせたかったりしたので OmniAuth を上流とした認可サーバーを作ることになったのだった (Lambda+DynamoDB で動きます)。
詳細は RubyKaigi 2023 の Lightning Talk での発表も合わせて参照されたい。具体的には RubyKaigi NOC での AWS や Grafana へのログインで活用している。特にチームメンバーに渡す Google Workspace アカウントとかは存在しないので、GitHub のチームに属するかどうかを見たりみたいな感じでやりたかったのであった。OAuth 2 アクセストークンも普通に出せるため、sorah/mairu との組み合わせも完璧で便利につかっている。
さてそんな Himari であったが 2023 年に作成して以来安定していたため大きな新機能追加などは行ってこなかった。そんな中、社内で社員向け MCP サーバー用の認証の相談がきて、Himari を使ってアクセストークンを出してしまうのが楽だなと思い、MCP での利用に向けいくつか機能を追加し MCP サーバーでも便利に利用できるようにした。そもそもこのブログでちゃんと Himari について書いてなかったので軽く紹介したい。
実際に MCP で使う
フルの設定方法は https://github.com/sorah/himari を参照。Himari は設定を Rack ミドルウェア経由で入れるため、config.ru にどんどん書いていけば良い。
scopes = %w(openid offline_access mcp)
use(Himari::Middlewares::Config,
issuer: 'https://himari.invalid',
# Devin など一部のクライアントは認可サーバーメタデータの scopes_supported に入れておかないと MCP サーバー側で WWW-Authenticate ヘッダで scope を伝えても無視する
scopes_supported: scopes,
# ...
)
# MCP向けに動的な client registration を有効化しておく
use(Himari::Middlewares::DynamicClients, scopes:)
use(Himari::Middlewares::MetadataClients, scopes:)
# ... omniauth の設定, claims rule, authentication rule は割愛...
# client と authorize request 両方に `mcp` scope があれば認可してトークンを発行する Authorization Rule
use(Himari::Middlewares::AuthorizationRule, name: 'mcp') do |context, decision|
next decision.skip!("client needs scope 'mcp' support") unless context.client.scopes&.include?('mcp')
next decision.skip!("scope 'mcp' is missing in request") unless context.scopes&.include?('mcp')
decision.claims[:scope] = context.scopes.join(' ')
decision.allowed_claims.push(:scope)
decision.lifetime = Himari::LifetimeValue.new(access_token: 3600, id_token: 3600, refresh_token: (86400 * 24) + 16)
# RFC 9068 準拠の JWT フォーマットな access token を出す
decision.mint_jwt_access_token = true
decision.allow!
end
こんな感じで Himari を利用した認可サーバーを立てておくと後はいい感じになる。dynamic client registration か client id metadata で OAuth クライアントとして登録し、mcp scope を要求すればアクセストークンが出てくる。
例示として、MCP サーバーとして Amazon Bedrock AgentCore Gateway で使う場合は CUSTOM_JWT で himari に誘導したりアクセストークンを検証したりできる。Himari からはアクセストークンを JWT で出すようにして、そして動的クライアントなので audience は検証せず issuer と scopes だけ見るという形。
resource "aws_bedrockagentcore_gateway" "test" {
name = "test"
role_arn = "..."
protocol_type = "MCP"
authorizer_type = "CUSTOM_JWT"
authorizer_configuration {
custom_jwt_authorizer {
discovery_url = "https://himari.invalid/.well-known/openid-configuration"
allowed_scopes = ["mcp", "offline_access"]
}
}
}
自分で認証部分も書いている場合は、 mint_jwt_access_token は利用せず access token を受け取って Himari の token introspection (userinfo API) を利用してアクセストークンを検証しても良い。
AWS スタックでやろうと思うと Cognito になるが、そこだけ自分で DCR 対応を Lambda で書いたりみたいな中途半端な面倒くささがある。全部 Himari にすると Lambda 一つなのでおすすめだ。
MCP で必要な機能の追加
ここからは、これまで素朴な OAuth 2.0 認可サーバー/OIDC プロバイダであった Himari に足りなかった機能を軽く紹介する。早い話、MCP specification: Authorization (2025-11-25) では以下の標準へ準拠が求められている。
- OAuth 2.1 (draft-ietf-oauth-v2-1-13)
- OAuth 2.0 Authorization Server Metadata (RFC 8414)
- OAuth 2.0 Dynamic Client Registration Protocol (RFC 7591)
- OAuth Client ID Metadata Documents (draft-ietf-oauth-client-id-metadata-document-00)
以下については、認可サーバーではなく MCP サーバー側で実装が必要。これと Authorization Server Metadata を利用して認可サーバーについて MCP クライアントへ教えて認可フローを開始してもらう必要があるのだ。
OAuth 2.1
現状まだ draft である OAuth 2.1 は OAuth 2.0 が出てから今日に至るまでに発行された RFC をまとめた内容になっている。推奨されなくなった仕様を削除・禁止したり、逆にセキュリティ上の理由などで追加されてきた仕様が要求になっていたりする。まだドラフトではあるが基本的には OAuth 2.1 を参照しながら実装するとベストプラクティスに沿った OAuth 2 クライアントやサーバーを作れるのでおすすめだ。非推奨になった機能に依存していない限り OAuth 2.0 とは互換性はある。
Himari は 2023 年に初期実装を行った認可サーバーで、必要な機能に対して最小限の実装になっている。その時点での RFC や I-D を踏まえて非推奨になったものや利用しないだろうと思われる機能はそもそも実装していないため、OAuth 2.1 で削除されたものはほぼ実装されておらず、推奨されたものも殆どが実装されている。代表的な例は PKCE。
今回の対応で実装したのは:
- Section 8.4.2.: loopback URL の redirect_uri 検証でポート番号を無視するという変更。CLI やデスクトップアプリだと 127.0.0.1 へのリダイレクトが使われがちだが、その際に選ばれるポート番号は ephemeral port でランダムになるため、redirect_uri の一致を検証するにあたって localhost ではポート番号を無視するという仕様。MCP クライアントは当然 CLI やアプリでもありがちで、この仕様へ沿う必要がある。
- Section 7.14.: OAuth 2 クライアントはユーザーを通し redirect_uri で受け取った authorization code を発行元ではない別の認可サーバーへ渡してしまうのを防ぐ必要がある。認可サーバーごとに redirect_uri を分けるといった対応があるが、RFC 9207 という手法も存在する。これは
issに issuer URL を載せて返すというだけの仕様で、実装はそう難しくないためついでに実装した (rack-oauth2 gem への monkey patch はあるが…)。
OAuth 2.0 Authorization Server Metadata
RFC 8414 はその Introduction にも書かれているように、OIDC の discovery document: /.well-known/openid-configuration を generalize, OAuth の世界で通用する範囲で、あらためて標準化したものである。そのため OIDC プロバイダでありそれを実装済みである Himari は、単に well-known URL のエイリアスを増やすだけで実装できた。
この仕様で定義される metadata は認可サーバーの authorize, token endpoint やサポートしている仕様について広報するもので、MCP では MCP サーバーが WWW-Authenticate ヘッダー と protected resource metadata で MCP クライアントを OAuth 2 認可フローへ誘導する際に endpoint を調べたりで参照される。たとえば有名な例だと https://accounts.google.com/.well-known/oauth-authorization-server や https://login.microsoftonline.com/consumers/v2.0/.well-known/openid-configuration がそれにあたる。
- https://www.rfc-editor.org/info/rfc8414/
- https://github.com/sorah/himari/commit/d21182716c0efdc7868fc29ea6f440a7d6cb116b
OAuth 2.0 Dynamic Client Registration Protocol
RFC 7591 (DCR) も OIDC の dynamic client registration 仕様を generalize したものである。OAuth 2 では事前に何かしらの方法でクライアント登録を済ませ Client ID や Client Secret を受け取る必要がある。殆どの ID プロバイダではこれを各々プロプライエタリな方法でやっている (さまざまなアプリ登録画面) が、それをプロバイダに依らない標準仕様で出来るようにしようという内容。この仕様では initial_access_token といってあらかじめ登録に利用するためのアクセストークンを渡す方法も用意されているが、MCP というか大半の利用ではそれを求めず open registration になっているだろう。DCR は MCP 以外だと Mastodon での利用が個人的には印象にある。Mastodon も MCP もサーバーとクライアントがそれぞれ不特定多数であるため、利用者による個別の OAuth クライアント登録は煩雑かつ手間であり、そのため DCR が活用されている。
$ curl -X POST http://himari.localhost:1355/public/oidc/register \
-H 'Content-Type: application/json' \
-d '{
"client_name": "demo",
"redirect_uris": ["http://127.0.0.1:3000/callback"],
"grant_types": ["authorization_code"],
"response_types": ["code"],
"token_endpoint_auth_method": "client_secret_basic",
"scope": "openid"
}'
{
"client_name": "demo",
"client_id": "...",
"client_id_issued_at": ...,
"client_secret": "...",
"client_secret_expires_at": ...,
...
}
MCP クライアントは MCP サーバー経由で発見した認可サーバーへクライアント登録が済んでなければ DCR で登録を試みてから認可フローを開始する。Himari では client registration, signing key や各種 rule (policy) を item provider という抽象化層から提供できるようにしていたため、client registration も動的に対応するのは容易だった。DCR 用の Rack ミドルウェアがある場合エンドポイントが有効になり AS metadata でそれが広報され、登録された Client registration は DCR 用の ItemProvider からクエリされるようになっている。なので use Himari::Middlewares::DynamicClients するだけで機能が有効にできるのだった。
DCR の問題として、各ユーザーがインストールしたアプリケーションが各々 client 登録をする都合、不必要にそのレコードが認可サーバー側にどんどん増えていくため、リソース消費が過多となること。また、同じクライアントでも全員 client_id が違うため認可サーバー側で利用傾向を分析したりするのにも難がある。DCR では client secret を発行でき confidential client として機能させられるが、現代では client authentication を通さない public client でも PKCE で安全に認可コードフローを利用できるので、client secret を発行しておきたいモチベーションも薄いのだ。
そんな課題もあり、次に紹介する仕様、Client ID Metadata Documents で簡易化されたため、この方法はじき使われなくなっていくだろう。なお CIMD は 2025-11-25 版 MCP 標準から SHOULD になり、さらに DCR は執筆時点の MCP 標準最新 draft では deprecated とされている。deprecated とはいえ CIMD を利用できないクライアントも多数あるため実装した次第だった。
OAuth Client ID Metadata Documents
執筆時点でまだ Internet draft である Client ID Metadata Documents (CIMD) はそんな DCR の問題を解決するために発案され、MCP では先行して採用されている仕様になる。Client ID に HTTPS URL を指定してそこの well known URL を取得しに行くという、Authorization Server Metadata を利用した認可サーバーのディスカバリの逆と言えるような仕掛けだ。
極端な話、やっていることとしては redirect_uri を伝えるだけである。例として Claude の CIMD は https://claude.ai/oauth/claude-code-client-metadata にホストされている。client_secret は存在せず、public client として動作することになるだろう。これは単なる JSON ファイルの配信である CIMD ではシークレットを安全に共有できないため。前述の通り、今は PKCE などを適切に利用する前提であれば client authentication 無しでも安全に認可コードフローを利用できる。一応、confidential client 向けにも JWKs で公開鍵を渡して JWT assertion による認証を追加することが可能になっている (Himari では執筆時点未実装)。
この仕組みにより、認可サーバー運用側としては不必要な client_id の増加を防げるほか、同じアプリケーションを利用しているユーザーを特定しやすくなり制御しやすくなるだろう。実際、渡されたメタデータをどう処理するかは仕様に含んでおらず、少数のユーザーしか使っていない client は警告を出したりしても良いだろう、といったヒントがドラフトには書かれている。
そして、CIMD を実装する上での注意点は認可サーバーは渡された URL を元に HTTP リクエストを行いメタデータを取得する必要があるところだ。適切に SSRF 対策や最大レスポンスボディ長の制限を実装する必要があるだろう。I-D でも 5 KB を目安にすると良いと明記されている。
なお、Himari での実装は前述の ItemProvider として渡された id が URL だったらリクエストを送り取得してキャッシュするというシンプルな実装になっている。これも use Himari::Middlewares::MetadataClients 一つで有効にできるようにした。永続化を伴わない分 DCR より実装はかなり小さい。
- https://datatracker.ietf.org/doc/draft-ietf-oauth-client-id-metadata-document/
- https://github.com/sorah/himari/pull/15
Scopes
Himari はこれまで上流から受け取ったユーザー情報を変換して改めて ID token などを発行して下流のクライアントに受け流すというだけの存在だった。そのために最低限必要な実装になっていたのだけれど、ここからは今回信頼のおけない動的なクライアントに対応するために追加した実装となる。
まずこれまで、ID トークンの発行に寄与する openid scope だけ確認して特に認可する oauth scope は永続化はおろかルールの実装にも渡さないという状態だった。
今回 MCP でクライアントが任意になると区別する方法が scope 程度しかなく、Himari もついにクライアントごとにサポートする scope を設定できるようにし、scope をアクセストークンのデータで永続化するようになり、認可ルールの実装で取得できるようになった。
Consent Page
次に実装したのは Authorize endpoint で表示する認可の確認画面。これまでは運用者が手動で設定したクライアント、つまり信頼のおける 1st party clients を中心に利用する想定であったため認可の確認画面は未実装、ログインしていたら authorize endpoint は即座に認可コードを発行して redirect_uri にリダイレクトしていた。
今回の一連の実装によりその前提が崩れたため、skip_consent=true が設定されていない OAuth クライアントでは都度 consent page を表示するようになった。要求されているスコープをユーザーに表示して許可するか聞いてくるいつもの画面である。
一手間で実装をサボっていただけではあるのだが、今は LLM に投げればいいだけだからお手軽だと感じる。なお、永続化が面倒なので依然として過去の認可を記憶しておくような機能は実装していない。一般的には繰り返し認可フローを通る想定であればユーザーが盲目的に認可しないようにするという観点では実装した方が良いだろう。
JWT profile for OAuth 2.0 access tokens
今回 RFC 9068 のサポートも追加した。本来 OAuth 2 ではアクセストークンのフォーマットについて規定はない (リソースサーバーと認可サーバーの間でフォーマットの合意が取れていればよく、プロプライエタリで構わないためだ)。一方で認可サーバーを介さずにアクセストークンの検証をし情報を取り出す手段として JWT は良く用いられている。であれば、そこで JWT の中身が各認可サーバーでバラバラであるよりある程度統一感があった方が便利なんじゃないか、という趣旨で標準化された内容だ。
これは Amazon Bedrock AgentCore Gateway の CUSTOM_JWT authorizer が実際にアクセストークンに JWT を期待していて、厳密に RFC 9068 を求められているわけではないが、RFC 9068 に沿っておいてもその要件を満たすため実装した。実際、リソースサーバーと認可サーバーで実装が異なるときには JWT を使うのはまあまあ便利だろう (代案としては都度 token introspection を行うとかになるが、スケールさせるのがちょっと億劫なのだと思う)。
先の例にもあるが、Himari では認可ルールで ID token や userinfo に含める claims, token の lifetime を設定するため、そこでフォーマットも切り替えられるようにした。従来通りデフォルトは opaque な, himari のみ理解できる string となっている。
use(Himari::Middlewares::AuthorizationRule, name: '...') do |context, decision|
# ...
decision.mint_jwt_access_token = true
decision.allow!
end
Refresh Tokens
最後に実装したのは refresh_token。実際これまで使っていて毎日ログインし直すのが面倒なシーンは多々あったが一旦そういうもんということにしようと逃げていたところ、今回の用途のように使うと知らない間に認可が切れてしまうなと思って実装した。
設計としてはなんとなく考えていたものを LLM に終わらせてもらった。上流の OmniAuth 由来の認証プロバイダが存在する前提なので、たとえばそっちの refresh_token と userinfo を叩いてみてユーザーが生きているか確認する、みたいなプロセスが必要だったのである。また、OmniAuth 由来の認証を通してユーザーデータを持つセッション自体もそれに合わせて長く永続化する前提にする必要があり、多少の変更が必要となっていた。
ちょっと強引ではあるが、ログイン時に参照される ClaimsRule で decision.refresh_info を足しておくと refresh_token 利用時に再度ログインから認可の処理まで走る、というような感じになった。
use(Himari::Middlewares::ClaimsRule, name: 'initialize') do |context, decision|
next decision.skip! unless context.initial?
# ...
decision.initialize_claims!(sub: context.auth[:uid], preferred_username: context.auth[:info][:nickname], email: context.auth[:info][:email])
decision.user_data[:provider] = 'github'
decision.refresh_info = {
sub: decision.claims[:sub],
nickname: context.auth[:info][:nickname],
email: context.auth[:info][:email],
refresh_token: context.auth[:credentials][:refresh_token],
access_token: context.auth[:credentials][:token],
access_token_expires_at: context.auth[:credentials][:expires_at],
}
decision.continue!
end
def refresh(refresh_token) = ... # 実際には適当な実装が入っている
use(Himari::Middlewares::ClaimsRule, name: 'revalidate') do |context, decision|
next decision.skip! unless context.refresh?
fresh = refresh(context.refresh_info[:refresh_token])
next decision.deny!("upstream refused refresh") unless fresh
decision.initialize_claims!(
sub: context.refresh_info[:sub],
# ...
)
decision.refresh_info = context.refresh_info.merge(
refresh_token: fresh['refresh_token'] || context.refresh_info[:refresh_token],
access_token: fresh['access_token'],
access_token_expires_at: Time.now.to_i + fresh.fetch('expires_in', 0),
)
decision.continue!
end
Outro
今回 MCP サーバーでの利用を想定して放置していた Himari に大幅な機能拡張を行った。特に refresh token はブラウザに持たせるセッションから含めて調整しなければならず腰が重い状態で issue に雑な設計メモだけ書いて長らく放置していた…。が、最近はやるだけではあってもちょっと面倒と思っていることを最近は Claude Code とかで一発なのだいぶ楽。そういう現代なのでそもそも全部実装してもらえばよく、汎用的にした OSS をわざわざ作ったり宣伝するモチベーションは無くなってきているよなぁ、と思う。とはいえ、自分は公私跨いで使いたくなりがちでちゃんと作品として作るんだけど、物によっては、というか大半のシンプルな物は、設定を分離したり社内等に留まらず汎用化するのを面倒なものとかはもう OSS にせずその場で作って運用できちゃうよね、と思う。
ところで近況報告ではあるが最近は本業 (IVRy) で元気にプロダクト用の IDM を作っています。真に認証認可が仕事になってしまった。ログイン体験を良くしていくぞみたいな感じであるが、まずは順当に認可サーバーから出来あがりつつあり。LLMでめちゃ楽を出来ている実感はあるが、それでもわたしのチームに(この領域に一家言もてる)人はたりてません。助けてくれ……。
話を戻すと、SSO はあるが MCP 対応が面倒だしサーバーレスでやりたい人に sorah/himari はおすすめです。ストレージは DynamoDB とファイルシステムしかないし、鍵管理も AWS Secrets Manager 前提だけど、サードパーティの Rubygems で他のストレージやクラウドプロバイダ対応は拡張可能にはしてるので、好きに拡張して使ってくれたら嬉しい。