0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Dex をローカルで動かして OIDC の認可コードフローを「curl で手で」追ってみた

0
Posted at

本記事では、OIDC プロバイダ/ブローカーである Dex をローカルで動かし、認可コードフローを curl で一段ずつ手動で追ってみました。AWS などのクラウドは使わず、手元の Mac だけで完結します。

仕事で「アプリ → 会社の Dex → 統合ID」という三層の認証構成を扱う機会があったのですが、その過程で「Dex(ブローカー)が実際に何をしているのか」が地味に重要かつ分かりにくいポイントだと感じたので、今回はそこを手を動かして深掘りします。

TL;DR

  • Dex 自身が IdP(OIDCプロバイダ)。利用側アプリから見たログインの相手は、上流が誰であっても常に Dex。これがブローカーの肝。
  • 認可コードフローは authorize → login → approval → callback → token交換 の流れ。curl で追うと各段で何が起きているか分かる。
  • アプリが受け取る ID トークンの iss は常に Dex になる(上流が誰であっても)。
  • ハマった点
    • bcrypt ハッシュは1文字欠けても見た目で気づけず、ログインは無言で 401 になる(写し間違いに注意)。
    • example-appmake build では作られず、make examples が必要。
    • ログイン POST に使う state は、フォームの action に入っている値をそのまま使う(組み立て直すと 401)。

前提: macOS(Apple Silicon)、Go 1.26(brew install go)、Dex は公式リポジトリをソースからビルド。ポートは Dex=5556 / example-app=5555 を使います。クラウドは使いません。

全体像: Dex は「IdP プロキシ(ブローカー)」

今回作る構成はこうです。

[ブラウザ]
   │
   ▼
[利用側アプリ (example-app)]
   │  OIDC でログイン要求
   ▼
[Dex]  ← 上流なし(内蔵ユーザーDB)で完結
   └─→ Dex 自身が ID トークンを発行して利用側アプリへ返す

ポイントは Dex 自身が IdP(OIDCプロバイダ) であること。利用側アプリから見たログインの相手は常に Dex です。これは本番の「自社アプリ → 会社Dex → 会社統合ID」と同じ構造で、今回はその Dex 部分を内蔵ユーザーDBで動かして核となる部分を体感する、という整理です。

学習環境 本番でいうと
example-app(利用側) 自社アプリのフロント/BFF
Dex(ローカル) 会社の Dex
内蔵ユーザーDB 会社の統合ID(上流IdP)

工夫①: Dex と example-app をビルドする

公式リポジトリを clone してビルドします。ここで最初のハマりがありました。

git clone https://github.com/dexidp/dex.git
cd dex
make build      # → ./bin/dex が生成される
make examples   # → ./bin/example-app(利用側サンプルアプリ)が生成される

make build だけだと bin/dex しか出来ず、利用側アプリの example-app は作られません。example-appmake examples で別ビルドが必要です。「make build で両方できる」と思い込んでいて、最初ここで詰まりました。

工夫②: 学習用の設定 config-lab.yaml

Dex に「誰がクライアント(利用側アプリ)か」「どんなユーザーがいるか」を教える設定です。今回は上流 IdP を使わず、Dex 内蔵のユーザーDB(パスワードDB)でログインします。

issuer: http://127.0.0.1:5556/dex

storage:
  type: sqlite3
  config:
    file: dex-lab.db

web:
  http: 0.0.0.0:5556

# 利用側アプリ(example-app)の登録
staticClients:
  - id: example-app
    name: 'Example App'
    secret: ZXhhbXBsZS1hcHAtc2VjcmV0
    redirectURIs:
      - 'http://127.0.0.1:5555/callback'

# ★ここが「内蔵IdP」。上流なしでログインできる
enablePasswordDB: true
staticPasswords:
  - email: "admin@example.com"
    # bcrypt ハッシュ(平文 "password" のハッシュ。学習用)
    hash: "$2a$10$2b2cU8CPhOTaGrs1HRQuAueS7JTT5ZHsHSzYiFPm1leZck7Mc8T4W"
    username: "admin"
    userID: "08a8684b-db88-4b73-90a9-3cd1661f5466"

staticClients が「利用側アプリの登録」、staticPasswords が「内蔵ユーザー」です。redirectURIs に登録した値と、アプリが送ってくる redirect_uri が完全一致しないと弾かれます(後述)。

起動と OIDC Discovery

ターミナルを2枚使って起動します。

# ターミナル1: Dex
./bin/dex serve config-lab.yaml

# ターミナル2: 利用側アプリ
./bin/example-app

起動したら、まず OIDC Discovery を覗きます。OIDC クライアントはここを見て各エンドポイントの URL を知ります。

curl -s http://127.0.0.1:5556/dex/.well-known/openid-configuration
{
  "issuer": "http://127.0.0.1:5556/dex",
  "authorization_endpoint": "http://127.0.0.1:5556/dex/auth",
  "token_endpoint": "http://127.0.0.1:5556/dex/token",
  "jwks_uri": "http://127.0.0.1:5556/dex/keys",
  "id_token_signing_alg_values_supported": ["RS256"],
  "...": "..."
}

jwks_uri(/dex/keys)を開くと、ID トークンの署名検証に使う公開鍵(JWKS)が見えます。JWKS は JSON Web Key Set の略で、JWT の署名を検証するための公開鍵のセット(JSON)です。検証側はこの URL から鍵を取得して署名を確認するので、本番で「検証側が Dex の JWKS を取れない」系のトラブルは、まさにこの URL に到達できるかどうかの話になります。

本題: curl で認可コードフローを完走させる

ブラウザで http://127.0.0.1:5555 を開いてログインすれば一発ですが、中で何が起きているかを理解するために curl で一段ずつ追いました。Authorization Code Flow の流れはこうです。

(1) /dex/auth          authorize(ログイン要求)
(2) /dex/auth/local    ログイン画面へ(コネクタが1つなので自動遷移)
(3) ログインフォーム POST   認証情報を送る
(4) /dex/approval      同意(approve)
(5) /callback?code=... 認可コードがアプリに返る
(6) /dex/token         code を ID トークンに交換(バックチャネル)

(1)〜(2) authorize → ログイン画面

B="http://127.0.0.1:5556"
J=/tmp/cookies.txt

# authorize。response_type=code, scope に openid を含めるのが OIDC の肝
L1=$(curl -s -c $J -o /dev/null -w "%{redirect_url}" \
  "$B/dex/auth?client_id=example-app&redirect_uri=http://127.0.0.1:5555/callback&response_type=code&scope=openid+email+profile&state=abc123")

# ログイン画面まで辿る(コネクタが1つなので /dex/auth/local へ自動で飛ぶ)
curl -s -b $J -c $J -L -o login.html "$L1"

(3) 認証情報を POST

フォームの action 属性をそのまま抜き出して POST します。ここで注意点があって、Dex はログインリクエストを state(リクエストID)でサーバー側に紐付けています。フォームの action に入っている state をそのまま使う必要があり、自分で組み立てた古い state を使うと、それだけで 401 になります(一度ハマりました)。

ACTION=$(grep -o 'action="[^"]*"' login.html | head -1 \
  | sed 's/action="//;s/"$//;s/&/\&/g')

APPROVAL=$(curl -s -b $J -c $J -o /dev/null -w "%{redirect_url}" \
  --data-urlencode "login=admin@example.com" \
  --data-urlencode "password=password" \
  "$B$ACTION")

成功すると Dex のログに次が出ます。

level=INFO msg="login successful" connector_id=local user_id=08a8684b-... email=admin@example.com

(4)〜(5) 同意 → 認可コード

同意画面を approve で POST すると、/callback へ認可コード付きでリダイレクトされます。

REQ=$(echo "$APPROVAL"  | grep -o 'req=[^&]*'  | cut -d= -f2)
HMAC=$(echo "$APPROVAL" | grep -o 'hmac=[^&]*' | cut -d= -f2)

CB=$(curl -s -b $J -c $J -o /dev/null -w "%{redirect_url}" \
  --data-urlencode "req=$REQ" \
  --data-urlencode "hmac=$HMAC" \
  --data-urlencode "approval=approve" \
  "$B/dex/approval")

echo "$CB"
# → http://127.0.0.1:5555/callback?code=xxxxxxxx&state=abc123

最初に送った state=abc123 がそのまま返ってきていることも確認できます(CSRF 対策)。

(6) 認可コード → ID トークン交換(バックチャネル)

ここが、フロントチャネル(ブラウザ)ではなく アプリ ↔ Dex のサーバー間通信で行われる部分です。

CODE=$(echo "$CB" | grep -o 'code=[^&]*' | cut -d= -f2)

curl -s -u "example-app:ZXhhbXBsZS1hcHAtc2VjcmV0" \
  -d "grant_type=authorization_code" \
  -d "code=$CODE" \
  --data-urlencode "redirect_uri=http://127.0.0.1:5555/callback" \
  "$B/dex/token"

返ってきた id_token(JWT)のペイロードをデコードすると、こうなりました。

{
  "iss": "http://127.0.0.1:5556/dex",
  "sub": "CiQwOGE4Njg0Yi1kYjg4LTRiNzMtOTBhOS0zY2QxNjYxZjU0NjYSBWxvY2Fs",
  "aud": "example-app",
  "exp": 1782553747,
  "iat": 1782467347,
  "email": "admin@example.com",
  "email_verified": true,
  "name": "admin"
}

完走しました。iss が Dex 自身(http://127.0.0.1:5556/dex)、aud が利用側アプリ(example-app)、sub が Dex の払い出すユーザー識別子になっています。上流が誰であれ、アプリが受け取るトークンの発行者は Dex、というのがブローカーの本質だと体で分かりました。

ハマりどころ集

① bcrypt ハッシュは1文字欠けても無言で 401

これは完全に自分のやらかしなのですが、教訓として残しておきます。今回は手元の手順メモに staticPasswords の bcrypt ハッシュを書き写していたのですが、そのメモが末尾1文字(W)を取りこぼしていて、正しい ...Mc8T4W...Mc8T4 になっていました。それに気づかずコピーした結果、admin@example.com / password でログインしても 401 Unauthorized が返り続け、しばらく溶かしました。

ポイントは、bcrypt ハッシュは人間が見て正しさを判断できない不透明な文字列で、1文字欠けても見た目で気づけないこと。そしてログイン失敗は 401 が返るだけで「ハッシュが平文と一致していません」とは教えてくれないことです。設定が合っているはずなのに通らないときは、ハッシュが本当に平文と一致するかを先に確かめると早いです。Go の bcrypt なら一発で確認できます。

err := bcrypt.CompareHashAndPassword(
  []byte("$2a$10$...Mc8T4W"), []byte("password"))
// err == nil なら一致

そもそもハッシュは公式 examples/config-dev.yaml からコピーするか、htpasswd -bnBC 10 "" 'パスワード' | tr -d ':\n' で自分で生成すれば、こうした写し間違いは避けられます。

② example-app が make build で作られない

make build で生成されるのは bin/dex だけです。利用側アプリは make examples が必要。「ビルドしたのにアプリが起動できない」となったら、まずここを疑うと良いです。

③ ログイン POST の state を組み立て直すと 401

Dex はログインリクエストを state(リクエストID)でサーバー側に紐付けています。これは CSRF 対策の state であると同時に、Dex 内部ではリクエストの紐付けキーでもあります。なので、フォームの action に入っている state をそのまま使うのが正解で、URL から自分で組み立て直したり使い回したりすると 401 になります。

④ redirect_uri は完全一致が必須

送る redirect_uristaticClients.redirectURIs と完全一致していないと Unregistered redirect_uri で弾かれます。スキーム・ポート・末尾スラッシュ・パスのどれか1文字でも違うとアウトです。本番で redirect_uri 未登録にハマる理由が、体験として腹落ちしました。

まとめ

  • Dex 自身が IdP。アプリから見たログインの相手は、上流が誰でも常に Dex。これがブローカーの肝。
  • 認可コードフローは authorize → login → approval → callback → token交換。curl で追うと各段の役割が見える。
  • アプリが受け取る ID トークンの iss は常に Dex。aud は利用側アプリ、sub は Dex が払い出す識別子。
  • ハマりどころ: bcrypt ハッシュの写し間違い(無言の401)、make examples(example-app)、state の使い回し(401)、redirect_uri の完全一致。
  • JWKS(/dex/keys)への到達性は検証側の生命線。Discovery → jwks_uri は最初に確認しておくと安心。

ブラウザでポチポチするだけだと「ログインできた」で終わってしまいますが、curl で一段ずつ追うと、OIDC の用語(iss / aud / sub / state / jwks_uri)が実物と結びついて一気に腹落ちしました。Dex やブローカー型 IdP が何をしているのか分からない、という方の参考になれば幸いです。

参考リンク

0
0
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
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?