Skip to content

OAuthと認可フロー

実装: crypto/oauth/ / 実行: go test ./crypto/oauth/

第三者アプリに「自分の写真を読ませたい」。だがパスワードを渡せば全権限を握られ、範囲も絞れず取り消せない。OAuthはパスワードの代わりにトークンを渡す。ユーザは認可サーバでログインし、アプリには「写真を読む」だけのトークンが届く。認可コードフローを組む。ブラウザ経由には短命のコードだけを流し、トークンは直接通信で交換する。PKCEで、コードを横取りされても検証子がなければ使えないことを確かめる。

この章で作るもの

認証と認可(JWT)で、トークンで本人性を運ぶ仕組みを見た。だがもう一つ大きな問題がある。第三者のアプリに、自分のデータへのアクセスを許したいときだ。写真印刷サービスに、自分のクラウドストレージの写真を読ませたい。素朴には、そのサービスに自分のパスワードを教えればいい。だがこれは危険極まりない。パスワードを教えれば、写真を読むだけでなく、消すことも、設定を変えることも、他のあらゆる操作もできてしまう。範囲を「写真を読むだけ」に絞れないし、後から取り消すにはパスワードを変えるしかない。しかもサービス側にパスワードが平文で保存されるかもしれない。

OAuth は、この委譲の問題を解く。パスワードを渡す代わりにトークンを渡す。ユーザは、信頼する認可サーバ(クラウドストレージ側)で直接ログインする。第三者アプリはユーザのパスワードを一切見ない。代わりにアプリには、「写真を読む」という範囲(scope)と有効期限を持つトークンが渡る。このトークンでできるのは写真を読むことだけで、期限が切れれば無効になり、必要なら取り消せる。この章では、その受け渡しの中核である認可コードフローを、横取り対策の PKCE 付きで実装する。

ユーザ ── ①ログイン ──▶ 認可サーバ
アプリ ◀─ ②コード ────  (フロントチャネル/ブラウザ経由。短命なコードのみ)
アプリ ── ③コード+検証子 ▶ 認可サーバ  (バックチャネル/直接通信)
アプリ ◀─ ④トークン ────  (ここで初めてトークンが渡る)
アプリ ── ⑤トークン ────▶ 資源サーバ → scope を確認してアクセス許可
認可コードフロー。フロントチャネル(ブラウザ)には短命のコードだけ。トークンはアプリとサーバの直接通信(バックチャネル)で交換する

順に見ていく。

  1. パスワードでなくトークンを委譲: 範囲(scope)と期限つき。パスワードは第三者アプリに触れない
  2. コードとトークンを分ける: ブラウザ経由には短命のコードだけ流し、トークンは直接通信で交換する。トークンが履歴やリダイレクトに残らない
  3. PKCE でコード横取りを無効化: 検証子のハッシュを先に預け、交換時に原本を示す。コードを盗んでも検証子がなければ使えない

① トークン: 範囲と期限を持つ許可証

まず渡すトークンを作る。トークンは subject(誰の)・scope(何ができる)・期限を持ち、それに HMAC 署名を付ける。資源サーバは署名を確かめてから scope を見る:

go

// 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 に乗り、ブラウザの履歴やサーバのアクセスログ、リファラに残りうる。トークンをそこに流せば漏洩の危険がある。そこでフロントチャネル(ブラウザ経由)には、それ単体では無力な短命のコードだけを流す。アプリはそのコードを、自分とサーバの直接通信(バックチャネル)でトークンに交換する:

go

// 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 が「コード横取り後の交換」を防ぐ。二段構えだ。

動かす

下のデモは認可コードフローを段階的に追う。正規のアプリはコードと検証子を揃えてトークンを得る。攻撃者がコードを横取りする筋では、検証子を持たないので交換に失敗する様子が見える。

デモOAuth 認可コードフロー(PKCE)トークン発行
正規のアプリコード横取り
検証子を生成し challenge を預ける
① ユーザが認可サーバでログイン
② コードがフロントチャネルで返る
③ コード+検証子をバックチャネルで送る
④ PKCE 検証
④ トークン発行 → ⑤ 資源アクセス
検証子を生成し challenge を預ける
アプリは検証子 "s3cr3t-verifier-42" を秘密に持ち、そのハッシュ 951a9ca5ae5529ec… だけを認可要求に添える
1 / 6

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 対策は設計の観点で述べるに留めた

参考資料