OAuthと認可フロー
実装:
crypto/oauth// 実行:go test ./crypto/oauth/
第三者アプリに「自分の写真を読ませたい」。だがパスワードを渡せば全権限を握られ、範囲も絞れず取り消せない。OAuthはパスワードの代わりにトークンを渡す。ユーザは認可サーバでログインし、アプリには「写真を読む」だけのトークンが届く。認可コードフローを組む。ブラウザ経由には短命のコードだけを流し、トークンは直接通信で交換する。PKCEで、コードを横取りされても検証子がなければ使えないことを確かめる。
この章で作るもの
認証と認可(JWT)で、トークンで本人性を運ぶ仕組みを見た。だがもう一つ大きな問題がある。第三者のアプリに、自分のデータへのアクセスを許したいときだ。写真印刷サービスに、自分のクラウドストレージの写真を読ませたい。素朴には、そのサービスに自分のパスワードを教えればいい。だがこれは危険極まりない。パスワードを教えれば、写真を読むだけでなく、消すことも、設定を変えることも、他のあらゆる操作もできてしまう。範囲を「写真を読むだけ」に絞れないし、後から取り消すにはパスワードを変えるしかない。しかもサービス側にパスワードが平文で保存されるかもしれない。
OAuth は、この委譲の問題を解く。パスワードを渡す代わりにトークンを渡す。ユーザは、信頼する認可サーバ(クラウドストレージ側)で直接ログインする。第三者アプリはユーザのパスワードを一切見ない。代わりにアプリには、「写真を読む」という範囲(scope)と有効期限を持つトークンが渡る。このトークンでできるのは写真を読むことだけで、期限が切れれば無効になり、必要なら取り消せる。この章では、その受け渡しの中核である認可コードフローを、横取り対策の PKCE 付きで実装する。
ユーザ ── ①ログイン ──▶ 認可サーバ
アプリ ◀─ ②コード ──── (フロントチャネル/ブラウザ経由。短命なコードのみ)
アプリ ── ③コード+検証子 ▶ 認可サーバ (バックチャネル/直接通信)
アプリ ◀─ ④トークン ──── (ここで初めてトークンが渡る)
アプリ ── ⑤トークン ────▶ 資源サーバ → scope を確認してアクセス許可順に見ていく。
- パスワードでなくトークンを委譲: 範囲(scope)と期限つき。パスワードは第三者アプリに触れない
- コードとトークンを分ける: ブラウザ経由には短命のコードだけ流し、トークンは直接通信で交換する。トークンが履歴やリダイレクトに残らない
- PKCE でコード横取りを無効化: 検証子のハッシュを先に預け、交換時に原本を示す。コードを盗んでも検証子がなければ使えない
① トークン: 範囲と期限を持つ許可証
まず渡すトークンを作る。トークンは subject(誰の)・scope(何ができる)・期限を持ち、それに HMAC 署名を付ける。資源サーバは署名を確かめてから scope を見る:
// hexBytes は指紋を 16 進文字列にする。
func hexBytes(b [hash.Size]byte) string {
const hexdigits = "0123456789abcdef"
out := make([]byte, hash.Size*2)
for i, c := range b {
out[i*2] = hexdigits[c>>4]
out[i*2+1] = hexdigits[c&0xf]
}
return string(out)
}
// issueToken は subject と scope と有効期限を HMAC で署名したトークンを作る。
// 形式は "sub|scope|exp.署名"。署名があるので中身を書き換えると検出できる。
func issueToken(secret []byte, sub, scope string, exp int) string {
body := sub + "|" + scope + "|" + itoa(exp)
tag := hash.HMAC(secret, []byte(body))
return body + "." + hexBytes(tag)
}
// parseToken は署名を検証し、正しければ (sub, scope, exp) を返す。
func parseToken(secret []byte, token string) (sub, scope string, exp int, ok bool) {
dot := strings.LastIndexByte(token, '.')
if dot < 0 {
return "", "", 0, false
}
body, sig := token[:dot], token[dot+1:]
want := hexBytes(hash.HMAC(secret, []byte(body)))
if !constEq(sig, want) {
return "", "", 0, false // 署名が合わない = 改ざん
}
parts := strings.Split(body, "|")
if len(parts) != 3 {
return "", "", 0, false
}
e, ok := atoi(parts[2])
if !ok {
return "", "", 0, false
}
return parts[0], parts[1], e, true
}
// ResourceServer は保護された API。トークンを検証してアクセスを判断する。
type ResourceServer struct {
secret []byte
now int
}
// NewResourceServer は認可サーバと同じ秘密鍵を持つ資源サーバを作る。
func NewResourceServer(secret []byte) *ResourceServer { return &ResourceServer{secret: secret} }
// Advance は論理時計を進める。
func (rs *ResourceServer) Advance(d int) { rs.now += d }
// Validate はトークンの署名・有効期限・scope を確かめる。
// requiredScope を含まなければ拒否する(最小権限)。
func (rs *ResourceServer) Validate(token, requiredScope string) bool {
_, scope, exp, ok := parseToken(rs.secret, token)
if !ok || rs.now >= exp {
return false
}
return hasScope(scope, requiredScope)
}
func hasScope(granted, required string) bool {
for _, s := range strings.Fields(granted) {
if s == required {
return true
}
}
return false
}トークンに署名があるのは、資源サーバがデータベースを引かずに正しさを確かめられるようにするためだ(JWTと同じ発想)。中身を書き換えれば署名が合わなくなる。テストで、トークンの scope を read から write に書き換えると検証で弾かれることを固定した。Validate は必ず必要な scope を要求する。写真を読むトークンで書き込み API は叩けない。この最小権限が、トークンを渡す安全性の核心だ。仮にトークンが漏れても、できるのは scope の範囲だけで、期限が切れれば無効になる。
② 認可コードフロー: コードとトークンを分ける
なぜ、直接トークンを渡さずに、いったんコードを挟むのか。ユーザのブラウザを経由してアプリに何かを返すとき(リダイレクト)、その値は URL に乗り、ブラウザの履歴やサーバのアクセスログ、リファラに残りうる。トークンをそこに流せば漏洩の危険がある。そこでフロントチャネル(ブラウザ経由)には、それ単体では無力な短命のコードだけを流す。アプリはそのコードを、自分とサーバの直接通信(バックチャネル)でトークンに交換する:
// Challenge は PKCE の code_challenge を検証子から作る(そのハッシュの 16 進)。
// クライアントは検証子を秘密に持ち、この challenge だけを認可要求で先に預ける。
func Challenge(verifier string) string {
return hexBytes(hash.Sum([]byte(verifier)))
}
// grant は認可コードに紐づく保留中の許可。
type grant struct {
clientID string
sub string
scope string
challenge string // PKCE の code_challenge
exp int // コードの有効期限
used bool // 一度使ったら無効(再利用防止)
}
var (
// ErrBadCode は未知・失効・再利用されたコード。
ErrBadCode = errors.New("oauth: invalid or expired code")
// ErrBadVerifier は PKCE 検証子が challenge と一致しないとき。
ErrBadVerifier = errors.New("oauth: pkce verifier mismatch")
// ErrClientMismatch はコード発行時と交換時でクライアントが違うとき。
ErrClientMismatch = errors.New("oauth: client mismatch")
)
// AuthServer は認可サーバ。ユーザ認証の後にコードを発行し、コードをトークンに交換する。
type AuthServer struct {
secret []byte
now int
codeTTL int
tokenTTL int
codes map[string]*grant
ids *idGen
}
// NewAuthServer は認可サーバを作る。secret はトークン署名と資源サーバで共有する。
func NewAuthServer(secret []byte, codeTTL, tokenTTL int, seed uint64) *AuthServer {
return &AuthServer{
secret: secret,
codeTTL: codeTTL,
tokenTTL: tokenTTL,
codes: make(map[string]*grant),
ids: &idGen{state: seed},
}
}
// Advance は論理時計を進める。
func (s *AuthServer) Advance(d int) { s.now += d }
// Authorize はユーザ認証済みとして認可コードを発行する。
// ここで受け取るのは challenge だけ。検証子そのものは受け取らない(横取り対策)。
// フロントチャネル(ブラウザのリダイレクト)で返るのはこの短命なコードだけ。
func (s *AuthServer) Authorize(clientID, sub, scope, challenge string) string {
code := s.ids.next()
s.codes[code] = &grant{
clientID: clientID,
sub: sub,
scope: scope,
challenge: challenge,
exp: s.now + s.codeTTL,
}
return code
}
// Exchange はバックチャネルでコードをトークンに交換する。
// コードが有効で、クライアントが一致し、PKCE 検証子のハッシュが預けた
// challenge と一致したときだけトークンを発行する。コードは一度で使い切る。
func (s *AuthServer) Exchange(clientID, code, verifier string) (string, error) {
g, ok := s.codes[code]
if !ok || g.used || s.now >= g.exp {
return "", ErrBadCode
}
if g.clientID != clientID {
return "", ErrClientMismatch
}
if Challenge(verifier) != g.challenge {
return "", ErrBadVerifier // 検証子の原本を示せない = 横取りしただけの者
}
g.used = true // 再利用を防ぐ
return issueToken(s.secret, g.sub, g.scope, s.now+s.tokenTTL), nil
}Authorize はユーザ認証の後にコードを発行する。ここで渡るのはコードだけで、トークンはまだ出ない。Exchange で、アプリがコードをトークンに交換する。このバックチャネルは、フロントと違って第三者に覗かれない。コードは一度使えば無効になる(used)。テストで、同じコードを二度交換しようとすると ErrBadCode で弾かれることを固定した。仮にコードが漏れても、一度使われていれば、あるいは短い有効期限が切れていれば、使えない。
③ PKCE: 横取りされたコードを無力にする
コードの単回使用と短命化だけでは、まだ穴がある。スマホアプリや SPA のような公開クライアントは、秘密鍵(client secret)を安全に隠せない。アプリのバイナリや JavaScript は誰でも読めるからだ。すると、リダイレクトを乗っ取ってコードを横取りした攻撃者が、そのコードをトークンに交換できてしまう。PKCE(Proof Key for Code Exchange)はこれを塞ぐ。
仕組みはこうだ。アプリは毎回、検証子(code verifier)をランダムに作る。そのハッシュ(challenge)だけを、認可要求と一緒に先に預ける。検証子そのものは手元に秘密に持つ。後でコードをトークンに交換するとき、アプリは検証子の原本を示す。サーバは、その原本をハッシュして、先に預かった challenge と一致するかを確かめる。一致すれば、このコードを最初に要求した本人だと分かる。攻撃者がコードを横取りしても、検証子を知らない。ハッシュから検証子は逆算できないので、原本を示せない。テストで、コードは奪えても検証子を知らない攻撃者が ErrBadVerifier で弾かれることを固定した。コードとトークンの分離が「経路上の漏洩」を防ぎ、PKCE が「コード横取り後の交換」を防ぐ。二段構えだ。
動かす
下のデモは認可コードフローを段階的に追う。正規のアプリはコードと検証子を揃えてトークンを得る。攻撃者がコードを横取りする筋では、検証子を持たないので交換に失敗する様子が見える。
PKCE の challenge は実際にハッシュ計算している。OAuth はパスワードでなくトークンを委譲する。 ブラウザ経由には短命のコードだけを流し、トークンは直接通信で交換するので、経路に残らない。 PKCE は検証子のハッシュを先に預け、交換時に原本を示させる。コードを横取りしても検証子を 知らなければ交換できない。範囲(scope)を絞ったトークンなら、漏れても被害はその範囲に留まる。
設計の観点
- 公開クライアントは PKCE 必須: SPA・モバイルは secret を隠せない。PKCE なしの認可コードフローや、トークンを直接返す暗黙フローは使わない。OAuth 2.1 は PKCE を全クライアントに必須化
- scope は最小に: アプリに渡す権限は必要最小限。読むだけなら read だけ。過大な scope は漏洩時の被害を広げる
- リダイレクト URI の厳密一致: コードを送り返す先は事前登録した URI と完全一致で検証する。緩いとコードを攻撃者のサイトへ誘導される
- 認証(OIDC)と認可(OAuth)は別物: OAuth は「何ができるか」の認可。ユーザが誰かの認証は OpenID Connect の id_token が担う。混同すると認証の欠陥になる
- state パラメータで CSRF 対策: 認可要求に予測不能な state を付け、応答で照合する。PKCE と別に、フロー自体の CSRF を防ぐ
対照と実例
| 渡し方 | パスワード露出 | 範囲制限 | 取り消し | 評価 |
|---|---|---|---|---|
| パスワード共有 | する | 不可 | パスワード変更のみ | 論外 |
| 暗黙フロー(トークン直接) | しない | 可 | 可 | トークンが経路に残る。非推奨 |
| 認可コードフロー | しない | 可 | 可 | 標準。ただし公開クライアントは要 PKCE |
| 認可コード + PKCE | しない | 可 | 可 | 推奨(OAuth 2.1) |
裏どり:
- RFC 6749 (OAuth 2.0): 認可フローの基本仕様。コード・トークン・scope の定義
- RFC 7636 (PKCE): コード横取り対策の仕様。この章の Challenge / verifier がそれ
- OAuth 2.1: 暗黙フローを廃し、PKCE を必須化する最新の統合案。この章の設計はこれに沿う
- OpenID Connect: OAuth の上に認証(id_token)を載せた層。「ログイン」の実装はこちら
簡略化したこと
- 自己完結トークン: HMAC 署名の中身入りトークン。実物は JWT や、不透明トークン+イントロスペクション
- OIDC の id_token なし: 認可に絞り、認証(誰か)は扱わない
- リフレッシュトークンなし: アクセストークンのみ。更新は省略
- リダイレクト検証は簡略: redirect_uri の厳密一致や state による CSRF 対策は設計の観点で述べるに留めた
参考資料
- RFC 6749: OAuth 2.0 — 認可フローの基本仕様
- RFC 7636: PKCE — コード横取り対策
- OAuth 2.0 Simplified (Aaron Parecki) — フローの実務的な解説
- 実装: crypto/oauth