4
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【セキュリティ入門】第2回 OAuth 2.0とは?認可の仕組みを図解で理解する

4
Posted at

はじめに

ログイン時に「Googleでログイン」「Facebookでログイン」というボタンがあるサイトを見たことがあると思います。

あれを押すと、Google の画面に飛んで、「このアプリに連絡先へのアクセスを許可しますか?」と聞かれて、戻ってくるとログインできているあの間に何が起きているのか、を追いかけるのがこの回です。

第1回では「認証(あなたは誰か)」と「認可(あなたは何をしてよいか)」を分けました。
今回のOAuth 2.0 は、そのうち認可のほう、しかも「自分の権限を、他のアプリに一部だけ貸す」という部分を担当する仕組みのお話です。

この記事でわかること

  • OAuth が生まれる前、何が困っていたのか
  • 登場人物4人(持ち主・アプリ・発行所・データ置き場)の役割
  • 「Googleでログイン」を押してから戻ってくるまでの流れ
  • なぜ一度「引換券」を受け取ってから本物のトークンに交換するのか
  • redirect_uri / scope / state が、それぞれどんな攻撃を防いでいるのか
  • うまく動かないとき、どこを見れば原因が分かるのか
  • 「OAuth はログインの仕組みではない」と言われる理由

この記事の対象読者

  • 第1回(認証と認可)を読んだ、または「認証と認可は別物」と分かっている人
  • 「Googleでログイン」の裏側を、なんとなくではなく説明できるようになりたい人

前提知識

ここだけ押さえておけば読めます。分からなければ下の用語メモで補ってください。

  • ブラウザがサーバに「リクエスト」を送り、サーバが「レスポンス」を返すやり取りがあること
  • URL に ?name=value のような形で情報をくっつけられること

用語メモ:リダイレクトとは?
サーバがブラウザに「そのページはこっちにあるから、自分でそっちへ行き直して」と指示することです。
ステータスコード 302 がその指示で、一緒に「行き先の URL」が渡されます。
ブラウザは言われたとおり、その URL へ自動でアクセスし直します。
OAuth はこのリダイレクトを使って、あなたのブラウザを「アプリ → Google → アプリ」と往復させます。

用語メモ:クエリパラメータとは?
URL の ? 以降にくっついている name=value&name2=value2 の部分のこと。
https://example.com/callback?code=abc123&state=xyz なら、codestate という2つの情報を運んでいます。
URL の一部なので、ブラウザのアドレスバーに丸見えです。 この「丸見え」が本記事の重要なテーマになります。

この記事のゴール

OAuth の流れを図に描いて説明できて、
「なぜこんなに手順が多いのか」を理由付きで言える状態になること。

本編

OAuth 以前:合鍵を渡していた時代

昔よくあった実装はこういうものでした。

家計簿アプリに、銀行の ID とパスワードをそのまま入力させる。
アドレス帳の連携も同じで、「Gmail のパスワードを入れてください」と外部サービスが要求していました。

これは「自分の家の合鍵を掃除業者に丸ごと渡す」ようなものです。
「リビングだけ掃除してほしい」だけなのに、寝室も金庫も全部開けられる鍵を渡している。

具体的に何が困るのか、4つに分けてみます。

困ること 合鍵のたとえ アプリで起きること
範囲を絞れない 全部屋の鍵を渡すことになる 「連絡先を読みたいだけ」でもメール送信・削除まで許すことになる
期限を切れない 「今日だけ」が指定できない 一度渡したら、パスワードを変えるまで永久に有効
個別に取り消せない 鍵を替えると家族の鍵も全部使えなくなる 1つの連携を切りたくてパスワードを変えると、他の連携も全部壊れる
預けた先の管理次第 業者が鍵を無くしたら終わり 連携先がパスワードを平文で保存していたら、そこが漏れた時点で本体アカウントが乗っ取られる

パスワードを「合鍵」ごと渡す4つの問題 — 範囲を絞れない・期限を切れない・個別に取り消せない・預けた先の管理次第。合鍵のたとえと、アプリで実際に起きることを並べて示した図

根っこにある問題はひとつです。

「パスワードを渡す」以外に権限を渡す方法がなかった。

パスワードは「その人自身」を表すものなので、渡した瞬間に範囲も期限も自分でコントロールできなくなります

そこで OAuth 2.0が置き換えたのが、「パスワードの共有」ではなく「権限の委譲(いじょう)」という考え方です。

渡すのはパスワードではなく、アクセストークンという別のもの。
これには「どこまで許すか(Scope)」と「いつまで有効か(expires_in)」が刻まれています。

用語メモ:アクセストークンとは?
「このアプリはこの範囲の操作をこの期限まで行ってよい」という入館パスのようなものです。
合鍵(パスワード)と違って、入れる部屋も期限も渡す側が決められます
Scope(範囲)と expires_in(期限・秒数)は第1回で扱った通りです。

登場人物は4人

OAuth の説明が難しく感じるのは、登場人物が4人いて名前が長いからです。
まずここを整理します。

正式名称 ひとことで言うと 具体例
Resource Owner(リソースオーナー) データの持ち主 あなた
Client(クライアント) データを使いたいアプリ 連携したい Web アプリ・スマホアプリ
Authorization Server(認可サーバ) 許可を判断してパスを発行する窓口 Cognito User Pool、Google、Auth0
Resource Server(リソースサーバ) パスを確認してデータを渡す場所 自社の API

イベント会場に例えると分かりやすくなります。

  • Resource Owner = 来場するあなた
  • Client = あなたの代わりに何かを取りに行きたい付き添いスタッフ
  • Authorization Server = 受付。本人確認をして、入館パスを発行する
  • Resource Server = 資料室。入館パスを見て、入っていい部屋かを確認して資料を渡す

OAuth の登場人物4人をイベント会場でたとえた図 — あなた(Resource Owner)・付き添い(Client)・受付(Authorization Server)・資料室(Resource Server)。受付が入館パスを発行し、付き添いが資料室でそのパスを提示して資料を受け取る流れと、受付と資料室は必ず別の役割として分けることを示している

ここで大事なポイントがひとつ。

受付(Authorization Server)と資料室(Resource Server)は、同じ建物にあっても役割は必ず分けて考えます。

「パスを発行する係」と「パスを確認する係」を混ぜてしまうと、あとで受付だけ外部サービス(Google など)に差し替えたくなった時に設計が根本から崩れます。

アプリには2種類ある

Client(アプリ)は、秘密を隠せるかどうかで2種類に分かれます。

種類 秘密を隠せる? 具体例
Confidential Client 隠せる(サーバの中に置ける) サーバサイドで動く Web アプリ
Public Client 隠せない SPA(ブラウザで動く JavaScript)、スマホアプリ

なぜ Public Client は隠せないのか?

ブラウザの JavaScript もスマホアプリも、利用者の手元に配られるからです。

開発者ツールを開けば読めるし、アプリを解析すれば取り出せる。
だから「秘密」になりません。

では、秘密を持てないアプリはどうやって身分を証明するのか。
そこで使うのが PKCE(ピクシー) という仕組みです。

用語メモ:PKCE とは?
Proof Key for Code Exchange の略(RFC 7636)。読み方は「ピクシー」。
ざっくり言うと「最初にお願いしたのと同じアプリであること」を後から証明させる仕組みです。
最初に「合言葉のヒント」を預けておいて、あとで「合言葉そのもの」を出させる、という流れです。

図にすると、4人の関係はこうなります。

矢印は4本だけです。

①であなたがアプリに頼み、②であなたが受付に許可を出す。 この2本がそろって初めて、③でアプリはパスを受け取れます。

「同意したあと、パスはどうやってアプリの手に渡るのか」
ここには引換券というもうひとつの登場人物がいます。

次の章で追いかけます。

「Googleでログイン」を押したときに起きていること

いよいよ本題です。
OAuth の標準的な手順は Authorization Code Grant(認可コードグラント) と呼ばれます。

用語メモ:グラント(Grant)とは?
「アクセストークンをもらうまでの手順の型」のことです。
状況に応じて何種類か定義されていますが、人が画面で「許可します」を押す一般的なケースはこの Authorization Code Grant を使います。

流れは6ステップです。

  1. アプリがあなたのブラウザを、受付(認可エンドポイント)へリダイレクトさせる
    (このとき「終わったらここへ戻して」と redirect_uri を一緒に伝えます)
  2. あなたが受付でログインし、「この範囲を許可しますか?」に同意する
  3. 受付が、指定された戻り先(redirect_uri)へ引換券(認可コード)を付けてリダイレクトで返す
  4. アプリが裏側で、引換券+身分証明をトークンエンドポイントへ送る
  5. アプリがアクセストークンを受け取る
  6. アプリがそのトークンを付けて、資料室(API)を呼ぶ

図にするとこうです。

ここで道が2本あることに注目してください。
この2本を分けたことが、OAuth 最大の工夫です。

呼び名 どんな道か 例えると
フロントチャネル ブラウザのリダイレクト経由。値が URL に載る はがき。運ぶ途中で読まれうる
バックチャネル アプリのサーバと認可サーバの直接通信。ブラウザを通らない 書留の直送便。第三者の目に触れない

なぜ「引換券」をはさむのか

素朴に考えると、「同意した時点でアクセストークンを返せばいいのでは?」と思いますよね。
実際、昔はそうする方式(Implicit Flow)もありました。でも今は非推奨です。

理由は、フロントチャネル=URL に値が載る道だから。
URL は思っている以上にあちこちに痕跡が残ります。

  • ブラウザの履歴やブックマーク
  • Referer ヘッダー(次に開いたページが外部の画像や広告を読み込むと、直前の URL が一緒に送られる)
  • Web サーバやロードバランサ、プロキシのアクセスログ
  • 共有端末なら、画面そのもの

つまり、URL に載せた値は「いつか漏れる」前提で設計しないといけない。

そこで OAuth は以下の発想を選択しました。

フロントチャネル(はがき)には、使い捨ての引換券だけを流す。
本物のアクセストークンは、バックチャネル(書留)でしか渡さない。

宅配便の不在票のイメージです。不在票を拾われても、本人確認ができなければ荷物は受け取れない。

そして認可コード(引換券)は、URL に載っても単体では使えないよう3重に縛られています

縛り 内容
一度きり 同じコードを2回使ってはいけません。再利用されたら認可サーバは拒否します(さらに、そのコードで発行済みのトークンも失効させることが推奨されています)
短命 有効期間は10分以内が推奨されています
交換に別の証明が要る client_secret(Confidential Client)か PKCE(Public Client は必須)がないと交換できません

用語メモ:client_secret とは?
アプリが認可サーバに「私は登録済みの正規アプリです」と名乗るための 合言葉(パスワード) です。
アプリごとに発行されます。

特に3つ目が本当に効いているところです。

「短いから安全」ではなく、「換金するのに別の鍵が要るから安全」。

コードを盗み見られても、合言葉を持っていない攻撃者はトークンに換えられません。

引換券をトークンに交換する

交換のとき、アプリが送るのは「券 + 誰が + どこへ返してもらったか」の3点セットです。

POST /oauth2/token
Content-Type: application/x-www-form-urlencoded

grant_type=authorization_code
&code=<引換券>
&redirect_uri=https%3A%2F%2Fapp.example.com%2Fcallback
&client_id=<アプリのID>

(Confidential Client なら、これに加えて client_secret による身分証明を付けます。
Public Client では、そこが PKCE の合言葉に置き換わります)

1行ずつ意味を見ます。

項目 意味
grant_type=authorization_code 「認可コードの手順で交換します」という宣言
code さっき受け取った引換券
redirect_uri 戻り先の URL。最初に受付へお願いしたときと同じ値を、もう一度送る
client_id どのアプリか

redirect_uri を2回送るのはなぜ?

ここが初見でいちばん「なんで?」となるところです。
だって、もう届いているんだから不要に見える。

答えは、照合するためです。

認可サーバは、最初のリクエストで受け取った redirect_uri を覚えています。
交換のときにもう一度送らせて、同じ値かどうかを突き合わせているのです。

これによって「券が本来の宛先以外で換金されること」を防いでいます。
仮に攻撃者が別の戻り先を仕込んで券を横取りできたとしても、交換時に元の値と照合されるので通りません。

つまり redirect_uri は、

  • 戻り先の指定であると同時に、
  • 交換時の照合キーでもある

という二役なのです。ここを理解すると、次の節の話がすっと入ります。

パラメータは「呪文」ではなく「防具」

認可リクエストの URL には、見慣れないパラメータがずらっと並びます。
暗記するのではなく、それぞれが何を守っているかで覚えてください。

パラメータ 何を指定する? 何を防いでいる?
response_type どの手順で行くか(code 間違ったフローを選ぶこと
client_id どのアプリか (識別のためで、防御目的ではない)
redirect_uri 戻り先の URL 攻撃者のサイトへ券を配達させること
scope 貸してほしい権限の範囲 権限の渡しすぎ
state なりすまし対策のランダム文字列 CSRF(下で説明します)
code_challenge PKCE の合言葉のヒント 券の横取り

実際の URL の形(GET /oauth2/authorize?response_type=code&...)は第1回で見た通りなので、ここでは再掲しません。

押さえておきたい3つの注意点

1. redirect_uri は「完全一致」で登録・検証する

https://app.example.com/ で始まっていれば OK」といった前方一致やワイルドカードでの登録はダメです。

攻撃者が https://app.example.com/attacker-page のような URL を指定できてしまうと、
そこへ認可コードが配達されてしまいます。

仕様上も、認可サーバは文字列の完全一致で検証しなければならない(MUST)と定められています。
例外は、スマホアプリなどが使う localhost のポート番号だけです(RFC 9700 2.1)。

2. CSRF 対策は必須。state はその手段のひとつ

用語メモ:ここでの CSRF とは?
Cross-Site Request Forgery の略。この文脈では、
攻撃者が自分のアカウントで取った認可コードを、被害者のブラウザにコールバックさせる」攻撃を指します。
成功すると、被害者のアカウントに攻撃者のデータが紐付いてしまいます。
(たとえるなら、あなたのロッカーに他人の荷物を勝手に入れさせるようなもの)

state は「送るときにランダムな文字列を作って保存しておき、戻ってきた値と一致するか確認する」ことで、「これは自分が始めた手続きの続きだ」と確認するための仕組みです。
保存と照合までがセットで、送るだけでは意味がありません。

仕様上、state そのものは RECOMMENDED(推奨)ですが、
CSRF を防ぐこと自体は必須(MUST) です。手段は state / PKCE / OIDC の nonce(第3回)のいずれかで、どれも実装しないという選択肢はありません (RFC 9700 2.1、4.7.1)。

3. PKCE は「SPA 専用」ではない

よくある誤解ですが、PKCE は Public Client では必須(MUST)Confidential Client でも推奨(RECOMMENDED) です(RFC 9700 2.1.1)。

「サーバサイドだから要らない」ではなく、「入れておくのが今の推奨」と覚えてください。

(複数の認可サーバを使い分ける構成では、さらに mix-up 攻撃という別の対策が必要になります。
単一の IdP を使う一般的な構成では関係しないので、いまは眺めるだけでOK。)

受け取ったトークンの使い方と、扱いの注意

もらったアクセストークンは、こういう形で API に付けて送ります。

GET /api/invoices HTTP/1.1
Authorization: Bearer <アクセストークン>

この Bearer という単語が重要です。

用語メモ:Bearer(ベアラ)とは?
持参人」という意味です。持っている人が誰であっても使えてしまう、ということ。
商品券や現金と同じで、落として拾われたら、拾った人がそのまま使えます。
(仕様は RFC 6750 で定義されています)

「誰が持っているか」を問わない仕組みなので、漏れたら即座に悪用される前提で設計します。
そのための対策が3つセットになっています。

  • 寿命を短くする(漏れても使える時間を限る)
  • 必ず TLS(https)を使う(通信経路で盗まれないように。クライアントは常に TLS を使わなければならない = MUST)
  • 失効させる手段を用意する

置き場所についても、注意点があります。

  • URL のクエリパラメータに入れて送るのは避ける(SHOULD NOT)
    • URL は履歴・ログ・Referer に残るから。
    • 認可コードをフロントチャネルに閉じ込めたのとまったく同じ理由。
  • 平文で送られる可能性がある Cookie に入れてはいけない(MUST NOT)。
    • Cookie に保存する実装は CSRF 対策が必須(MUST)
  • SPA の localStorage は XSS で読み出せます
    • 基本形は「メモリに持つ + 短命にする + リフレッシュする

うまくいかないときに見る場所

ここは実際に手を動かすと必ずぶつかるところなので、切り分け方を持ち帰ってください。

コツはひとつです。「どこでエラーが返ったか」をまず特定する。

エラーが返る場所は2つあり、形式がまったく違います

どこで返った? 見える形 代表的なエラー
認可エンドポイント(同意画面のあたり) ブラウザのアドレスバー?error=... が付いて戻ってくる access_denied / invalid_scope / unauthorized_client など
トークンエンドポイント(引換券の交換) HTTP 400 + JSON のレスポンスボディ invalid_grant / invalid_client など

実際の見え方はこうです(値は例)。

# 認可エンドポイントのエラー → アドレスバーに現れる
https://app.example.com/callback?error=access_denied
  &error_description=User%20denied%20the%20request&state=xYz123
// トークンエンドポイントのエラー  HTTP 400 のレスポンスボディ
{
  "error": "invalid_grant",
  "error_description": "Authorization code expired"
}

読み解きの勘所を、初級者がハマりやすい順に並べます。

症状 何が起きている? どうする?
コールバックに戻ってこない
(認可サーバ側にエラー画面)
仕様上、**不正なリダイレクト先へ飛ばしてはならない(MUST NOT)**ので、あえて何も返していない redirect_uriclient_id の登録値を確認する
access_denied バグではない。 利用者が同意画面で「拒否」を押しただけ 画面を壊さず、「もう一度試す」導線を用意する
invalid_grant 引換券まわりの失敗がまとめてここに落ちてくる 期限切れ・二重使用・redirect_uri の不一致を順に確認する
invalid_client アプリの身分証明の失敗 client_secret と、送り方(ヘッダーかボディか)を確認する

この中で、初級者がとくに時間を溶かすのは次の2つです。

「何も起きない」は情報です
コールバックに戻ってこないと、つい「まだ動いていない」と考えてしまいます。
でも認可サーバは、信用できない戻り先へは意図的に何も返しません
つまり「戻ってこないこと自体が、redirect_uriclient_id が違うという手がかり」なのです。

そしてもうひとつ、原因にたどり着きにくいのがこれです。

invalid_grant の犯人は、たいてい二重交換です
よくある事故が、SPA でコールバック画面が2回描画され、同じ code を2回交換してしまうというもの。
1回目は通るのに、2回目が失敗する」なら、まずこれを疑ってください。

なお error_description任意項目なので、空のこともあります
「詳細が出ないから分からない」で止まらず、認可サーバ側のログと突き合わせる前提で設計してください。

そして、切り分けのときに絶対にやってはいけないことがひとつ。

認可コードや client_secret をログに出さないこと。
デバッグのつもりで書いた console.log が、そのまま漏洩経路になります。

手順の型(グラント)と、今の推奨

OAuth にはいくつか手順の型がありますが、今も推奨されているものは限られています

グラント どんなとき 今の位置づけ
Authorization Code 人が「許可します」を押す一般的なケース 推奨(Public Client は PKCE 必須)
Client Credentials 人が介在しないサーバ同士の通信 推奨
Refresh Token トークンの更新 利用可
Implicit かつての SPA 向け 使うべきでない(SHOULD NOT)
Resource Owner Password Credentials パスワードを直接渡す 使ってはならない(MUST NOT)

下2つは「非推奨」でひとくくりにされがちですが、強さが違います

  • Implicit
    • アクセストークンを URL(フロントチャネル)に載せてしまう方式。だから SHOULD NOT
  • ROPC
    • アプリがユーザーのパスワードを直接受け取る方式。だから MUST NOT
    • これはこの記事の冒頭で見た「合鍵を渡す問題」を、OAuth の形式でやり直しているだけです。振り出しに戻ってしまう

用語メモ:なぜ古い記事と書いてあることが違うのか?
現在の推奨は RFC 9700 / BCP 240「Best Current Practice for OAuth 2.0 Security」(2025年1月発行)にまとまっています。
一方で、OAuth 本体の仕様書である RFC 6749 には、Implicit も ROPC も定義されたまま残っています
「仕様には載っている。でも今は使うべきでない」この状態が、古い解説記事と最新の推奨が食い違って見える原因です。発行年を見る癖をつけてください。

最後に大事な話:OAuth は「ログインの仕組み」ではない

「Googleでログイン」と書いてあるのに?と思いますよね。
でも、ここは本当に大事です。

OAuth が答えているのは「このアプリはこの操作をしてよいか」であって、「誰がログインしたか」ではありません。
実際、RFC 6749 にはユーザーの名前や ID を伝えるための標準的な仕組みが定義されていません

それでもアクセストークンを「ログインできた証」として使うと、こういう穴が空きます。

どういうことか
トークンの差し替え Bearer は「持っている=本人」を意味しません。他人のトークンを貼れば、その人としてログイン扱いされます
別アプリのトークンが通ってしまう 同じ認可サーバが発行した、まったく別のアプリ向けトークンでも区別できません。「誰宛てに発行されたか」を検証する仕組みが標準化されていないためです
ユーザー ID の取り方がバラバラになる 「プロフィール API を叩いて id を取る」といった独自実装になり、その応答を検証する方法も標準がありません

この穴を埋めるために標準化されたのが OpenID Connect(OIDC) です。

ID トークンという「誰がいつ認証されたか」を表す決まった形式と、
「誰宛てか」「どこが発行したか」を検証するための情報を、OAuth の上に足しています

つまり、

OAuth 2.0(権限を貸す) + 身元を伝える仕組み = OpenID Connect

というのが第3回の話になります。

よくある誤解

よくある誤解 実際は
「OIDC は OAuth とは別物」 OAuth の上に乗った拡張です。窓口も流れも同じで、openid と ID トークンが増えただけ。
「ID トークンを API 呼び出しに使ってよい」 用途違いです。API に送るのはアクセストークン。
「ID トークンを保存しておけばセッション管理はいらない」 ID トークンは過去の出来事の記録で、取り消せません。確認したら自分のセッションに変換します。
「メールアドレスをユーザー ID に使えばいい」 変わりうる値です。isssub の組で識別します。
sub はどこでも同じ値」 その OP の中でだけ一意です。設定によってはアプリごとにも変わります。
state を確認していれば nonce は要らない」 守っている対象が別です。そして nonce は送るだけでは無意味で、照合して初めて効きます
「Discovery を使っていれば安全」 取ってきた issuer が問い合わせ先と一致するかの確認まで含めて Discovery です。
「UserInfo が返した情報はそのまま使える」 sub の一致確認が必須です。
「検証はライブラリ任せでOK」 iss / aud の期待値と許可する署名方式は、アプリ側の設定責任です。初期値では埋まりません。

まとめ

観点 押さえるポイント
位置づけ OIDC は OAuth の上に「認証」を足した仕様。流れも窓口も第2回と同じ
OIDC の宣言 scopeopenid を入れることが OIDC の宣言で、これで id_token が返る
ID トークンの正体 本人確認しましたよ」という証明書。中の1項目1項目をクレームと呼ぶ
検証の順番 ハンコ(署名)→ 発行元(iss)→ 宛名(aud)→ 期限(exp)→ 受付番号(nonce
検証の材料 Discovery(案内ページ)と JWKS(印鑑の見本帳) から取る。案内ページ自体の issuer 確認も忘れずに
検証したあと 確認が終わったら ID トークンは捨てる。 自分のアプリのセッションに変換する。URL に載せない、期限を延ばさない
ユーザーの識別 isssub の組で。メールアドレスは使わない
OAuth との分かれ目 「自分のアプリのアカウントに紐付けるか」 が判断軸

次回予告

次回: 第3回「【セキュリティ入門】OpenID Connect(OIDC)とは?OAuthとの違いを図解で理解する」

前回: 第1回「【セキュリティ入門】認証と認可の違い|OAuth・OIDC・JWTを学ぶ前に固めておく土台

第3回では、OAuth に何を足すと「ログイン」として使えるようになるのかを、
ID トークンと scope=openid を軸に見ていきます。

参考リンク

4
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
4
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?