OAuthとは
OIDCについて調べていて、そもそも前提となるOAuth 2.0をよく分かっていないことに気づいたので、改めて整理してみる。
この記事では、以下がわかるようにまとめたい。
- [What] OAuth 2.0とはなにか?
- [Why] 何が課題で、OAuthは何を解決するのか?
- [How] どのようなフローで動くのか?グラントタイプはどう使い分けるのか?
OAuthとは?
OAuth 2.0は、ユーザーのパスワードを渡すことなく、あるアプリに自分のリソースへの限定的なアクセスを許可(認可)するための仕組みである。
例:「予定管理アプリに、自分のGoogleカレンダーの読み取りだけを許可したい」
OAuth以前の課題
OAuthがなかった頃は、アプリにGoogleのID・パスワードをそのまま渡すしかなかった。これには問題が多い。
- アプリにパスワードが漏れる・保存される
- アプリはカレンダーだけでなく、GmailもDriveも全部操作できてしまう
- アクセスを取り消すにはパスワード自体を変えるしかない
OAuthはこれを「パスワードの代わりに、権限を絞った期限付きのアクセストークンを渡す」ことで解決する。
登場人物
- リソースオーナー: ユーザー本人
- クライアント: アクセスを許可してもらいたいアプリ(例: 予定管理アプリ)
- 認可サーバー: ユーザーの同意を確認し、トークンを発行するサーバー(例: Googleの認可サーバー)
- リソースサーバー: 保護されたリソースを持つAPIサーバー(例: Google Calendar API)
client_id / client_secret はどこから来るのか
後述するフローの中で、クライアントは client_id と client_secret を使って認可サーバーとやりとりする。ここでふと疑問に思ったのが「この client_secret はそもそも何から生まれたのか?」ということ。
答えは、開発者が事前にクライアント(アプリ)を認可サーバーに登録したときに発行される。後述するシーケンス図の (1) 連携開始よりも前、開発者がアプリを作る段階での話であり、フローの中で発行されるものではない。また、ユーザーごとではなくアプリごとに1回発行されるものである点にも注意。OAuthのフローはすべて、この「クライアント登録」が済んでいることが前提になっている。
Googleの例だと、開発者がGoogle Cloud Consoleで「OAuthクライアント」を作成すると、その場で client_id と client_secret のペアが発行される。それぞれの役割は以下の通り。
client_id: アプリの公開識別子。「どのアプリからのリクエストか」を示すだけのもので、秘密ではない(認可画面のURLにもそのまま載る)。client_secret: アプリ自身が「登録済みの本物のクライアントであること」を認可サーバーに証明するためのパスワード。ユーザーの認証とは別に、アプリの認証に使われる。
つまりOAuthのフローでは、「ユーザー本人の確認(認可サーバーでのログイン&同意)」と「アプリの確認(client_secret)」の2つが独立して行われている。認可コードをトークンに交換するステップで client_secret が要求されるのは、仮に認可コードが盗まれても、client_secret を持たない攻撃者にはトークンへ交換できないようにするためである。
client_secret を持てないクライアントもいる
ここで重要な区別が、クライアントが client_secret を安全に保持できるかどうか。
- コンフィデンシャルクライアント: サーバーサイドのWebアプリなど。秘密情報をサーバー内に保持できるので
client_secretを使える。 - パブリッククライアント: SPAやモバイルアプリなど。コードがユーザーの手元に配布されるため、埋め込んだ秘密情報は取り出せてしまう。
client_secretを持たせても秘密にならない。
パブリッククライアントでは client_secret の代わりに、後述するPKCEで認可コードの横取りを防ぐ。
基本のフロー(認可コードフロー)
sequenceDiagram
actor User as ユーザー
participant Client as クライアント
participant Auth as 認可サーバー
participant RS as リソースサーバー
Note over Client,Auth: (0) 事前登録(開発者がアプリを登録し、client_id / client_secret を取得済み)
User->>Client: (1) 連携開始
Client-->>User: (2) 認可サーバーへリダイレクト(scope等)
User->>Auth: (3) ログイン&同意
Auth-->>User: (4) 認可コード付きでリダイレクト
User->>Client: 認可コードをクライアントへ
Client->>Auth: (5) 認可コード + client_secret
Auth-->>Client: (6) アクセストークン
Client->>RS: (7) トークン付きでAPIリクエスト
RS-->>Client: (8) リソース
ポイント:
client_id/client_secretは (0) の事前登録で発行済み。このフローの中で発行されるのは認可コードとアクセストークンだけ- ユーザーがパスワードを入力するのは認可サーバー(Google)の画面だけ。クライアントには渡らない
- トークンには
scope(権限の範囲)と有効期限がある - トークン交換(5〜6)はバックエンド間で行われ、ブラウザに露出しない
client_secret があるのに、なぜ認可コードが必要なのか?
事前登録の話を理解すると、次の疑問が湧いてくる。「クライアントはすでに client_secret を持っていて、それで認可サーバーに身元を証明できるなら、わざわざ認可コードをもらわなくても client_secret だけでトークンを発行してもらえばよいのでは?」
これは、2つのクレデンシャルが証明している内容がまったく別物だから成り立たない。
client_secretが証明するもの: 「このリクエストは登録済みのアプリXからのものである」というアプリの身元。それだけ。どのユーザーとも紐づいていない。- 認可コードが証明するもの: 「ユーザーYが、アプリXに対して、scope Zの範囲でアクセスを許可した」というユーザーの同意の事実。その場で発行される、短命で1回しか使えない引換券である。
アクセストークンは「どのユーザーの・どのリソースに・どの範囲で」アクセスできるかを表すものなので、発行には「アプリの身元」と「ユーザーの同意」の両方が必要になる。client_secret だけでは「誰のカレンダーを見たいのか」「そのユーザーは同意したのか」という情報がどこにもない。仮に client_secret だけで任意のユーザーのリソースに触れてしまったら、「アプリがユーザーの同意なく全データにアクセスできる」というOAuth以前の課題に逆戻りしてしまう。
トークン交換(手順5)で「認可コード + client_secret」の両方を出させているのは、この2つをセットで検証するためである。
- 認可コードだけ盗んでも、
client_secretがなければ交換できない(アプリの身元チェック) client_secretだけ持っていても、ユーザーの同意(認可コード)がなければトークンは出ない
ちなみに「client_secret だけでトークンをもらうフロー」も実は存在する。それが後述するクライアントクレデンシャルグラントで、ユーザーが存在せずアプリ自身のリソースにアクセスする場合に使う。逆に言うと、特定ユーザーのリソースに触りたい場合は必ずユーザーの同意の証明が必要、ということである。
では、認可コードをそのままアクセストークンとして使えないのか?
さらにもう一歩踏み込むと、こんな疑問も湧く。「認可コードが『ユーザーの同意の証明』なら、それをそのままアクセストークンとして使えばよいのでは?わざわざ交換するステップが必要なのか?」
これも成り立たない。理由は、2つが通る経路の危険度がまったく違うからである。
シーケンス図をよく見ると、認可コードとアクセストークンは別のルートを通っている。
- 認可コード: 認可サーバー → ブラウザ → クライアント、とブラウザを経由して渡される(フロントチャネル)。リダイレクトURLのクエリパラメータに乗るため、ブラウザ履歴・アクセスログ・プロキシなどに残り得る。
- アクセストークン: クライアントのバックエンドと認可サーバーの間で直接受け渡される(バックチャネル)。ブラウザには一切露出しない。
ここで問題になるのが、アクセストークンがベアラートークン(bearer = 持参人払い)だという点。リソースサーバーは「トークンを持ってきた者」を疑わず、持っていれば誰でも使えてしまう。そんな強力なものを、履歴やログに残るフロントチャネルに流すわけにはいかない。
そこでOAuthは役割を分割している。
| 認可コード | アクセストークン | |
|---|---|---|
| 通る経路 | フロントチャネル(ブラウザ経由) | バックチャネル(サーバー間) |
| 単体での効力 | なし(client_secret / PKCEがないと交換できない) | あり(持っていれば使える) |
| 寿命・回数 | 数分・1回きり | 期限内なら何度でも |
つまり認可コードは「露出する前提の場所を通るからこそ、単体では何の力も持たない引換券」として設計されている。盗まれても client_secret や code_verifier がなければ交換できず、正規クライアントが先に使えば1回きりで失効する。力を持つ本体(アクセストークン)は、露出しない安全な経路でしか渡さない。
ちなみに「交換を省略してアクセストークンをブラウザ経由で直接返す」フローこそが、グラントタイプの節で触れた非推奨のインプリシットフローである。まさにこの「ベアラートークンのフロントチャネル露出」が危険だとされて廃れた、という歴史がこの設計の答え合わせになっている。
scope
トークンで許可する操作の範囲。クライアントは認可リクエスト時に必要な範囲を申告し、ユーザーは同意画面でそれを確認する。
例(Google Calendar APIの場合):
https://www.googleapis.com/auth/calendar.readonly: カレンダーの読み取りのみhttps://www.googleapis.com/auth/calendar: カレンダーの読み書き
「カレンダーの読み取りだけ許可して、Gmailには触らせない」という最小権限を実現しているのがこの仕組みである。
アクセストークンとリフレッシュトークン
アクセストークンは漏洩リスクを抑えるため短命(Googleの場合は1時間程度)に設定されている。しかし、期限が切れるたびにユーザーへログイン&同意をやり直させるのは体験が悪すぎる。
そこで登場するのがリフレッシュトークンである。発行されるタイミングは認可コードフローの手順(6)、アクセストークンと同時。トークン交換のレスポンスに access_token と並んで refresh_token が入って返ってくる。(ただしOPによっては無条件には発行されない。Googleの場合、認可リクエストに access_type=offline を指定したときだけ返ってくる。)つまりバックチャネルでしか受け渡されない長命なトークンで、ユーザーを介さずに新しいアクセストークンを取得するためだけに使う。
sequenceDiagram
participant Client as クライアント
participant Auth as 認可サーバー
participant RS as リソースサーバー
Client->>RS: アクセストークン付きでAPIリクエスト
RS-->>Client: 401 Unauthorized(トークン期限切れ)
Client->>Auth: リフレッシュトークン + client_id / client_secret
Auth-->>Client: 新しいアクセストークン(+ 新しいリフレッシュトークン)
Client->>RS: 新しいアクセストークンでリトライ
RS-->>Client: リソース
2種類のトークンで役割を分けているのがミソ。
| アクセストークン | リフレッシュトークン | |
|---|---|---|
| 寿命 | 短い(分〜時間) | 長い(日〜無期限) |
| 送り先 | リソースサーバー | 認可サーバーのみ |
| 漏洩時の影響 | 期限までの間だけ悪用可能 | 大きい(トークンを再発行され続ける) |
リフレッシュトークンは影響が大きい分、認可サーバーとの間でしか使われず、ネットワーク上に露出する機会が最小限に抑えられている。さらに近年は、使用のたびに新しいリフレッシュトークンに差し替えて古いものを無効化するリフレッシュトークンローテーションも一般的で、盗まれたトークンが使われたことを検知できるようになっている。
グラントタイプの使い分け
OAuth 2.0には、トークンを取得するためのフロー(グラントタイプ)が複数定義されている。「誰が・どんな環境でトークンが欲しいのか」で使い分ける。
| グラントタイプ | 使う場面 | ユーザーの介在 |
|---|---|---|
| 認可コード(Authorization Code) | Webアプリ・SPA・モバイルなど、ユーザーの同意を得てリソースにアクセスする場合。迷ったらこれ + PKCE | あり |
| クライアントクレデンシャル(Client Credentials) | ユーザーが存在しないマシン間通信。バッチ処理やマイクロサービス間のAPI呼び出しなど、アプリ自身のリソースにアクセスする場合 | なし |
| デバイスコード(Device Code) | テレビやCLIツールなど、ブラウザや文字入力が使いにくいデバイス。別のデバイス(スマホ等)で認可する | あり(別デバイス) |
| リフレッシュトークン(Refresh Token) | 発行済みのリフレッシュトークンでアクセストークンを再取得する場合 | なし |
かつて存在した以下の2つは、現在は非推奨とされている。
- インプリシット(Implicit): SPA向けにトークンをブラウザへ直接返すフロー。トークンがURLに露出するため危険。現在は「認可コードフロー + PKCE」で置き換えられている。
- リソースオーナーパスワードクレデンシャル(ROPC): ユーザーのID・パスワードをクライアントが直接預かって送るフロー。「パスワードをアプリに渡さない」というOAuthの目的そのものに反しており、使う理由がない。
「非推奨の2つは使わず、ユーザーがいるなら認可コード(+ PKCE)、いないならクライアントクレデンシャル、入力が難しいデバイスならデバイスコード」と覚えておけばだいたい足りそう。
PKCE
認可コードの横取り対策。クライアントはリクエストごとにランダムな値(code_verifier)を生成し、そのハッシュ(code_challenge)を認可リクエストに付ける。トークン交換時には元の code_verifier を送り、認可サーバーがハッシュの一致を確認する。
これにより、認可コードだけを横取りしても「元のランダム値」を知らなければトークンに交換できない。前述の通り、client_secret を持てないパブリッククライアントの命綱であり、現在はコンフィデンシャルクライアントを含むすべてのクライアントで利用が推奨されている。
OAuthは「認証」ではない
OAuthはあくまで認可の仕組みで、「ユーザーが誰か」を証明する機能はない。これを認証に流用すると危険(トークン置き換え攻撃)。認証にはOAuthを拡張したOIDCを使う。
詳しくは別記事「OIDCとは」にまとめている。
まとめ
- OAuth 2.0は「パスワードを渡す代わりに、権限を絞った期限付きのアクセストークンを渡す」ための認可の仕組み。
client_id/client_secretはフローの前に、開発者がアプリを登録した時点で発行されるもの。client_secretが証明するのはアプリの身元、認可コードが証明するのはユーザーの同意で、トークンの発行には両方が必要。- 認可コードは「フロントチャネルを通る前提だからこそ、単体では無力な引換券」。力を持つアクセストークンはバックチャネルでしか渡さない。
- アクセストークンは短命にし、再取得はリフレッシュトークン(トークン交換時に同時発行)で行う。
- グラントタイプは「ユーザーがいるなら認可コード + PKCE、いないならクライアントクレデンシャル、入力が難しいデバイスならデバイスコード」で使い分ける。
- OAuthは認可の仕組みであり、認証にはOIDCを使う。
参考文献
RFC 7636: Proof Key for Code Exchange by OAuth Public Clients