本記事では、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-appはmake 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-app は make 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_uri が staticClients.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 が何をしているのか分からない、という方の参考になれば幸いです。
参考リンク
- Dex 公式: https://dexidp.io/
- リポジトリ: https://github.com/dexidp/dex
- Getting started: https://dexidp.io/docs/getting-started/