ユースケース — Device Code(RFC 8628)
device flow は、使いやすいキーボードやブラウザを持たない機器(TV、CLI、プリンタなど)が、ユーザの手元の別の端末で承認してもらうことでトークンを得る仕組みです。どんなときに選ぶのか、なぜ slow_down や expired_token があるのかといった概念は、先に Device Code 入門 を読んでください。このページは組み込み手順を扱います。
device_code の側です。device_code と user_code の違い
/device_authorization は 2 つの異なる識別子を返します。device_code は長い不透明文字列で、デバイス側だけが保持し、/token へのポーリングごとに送信します — 実質的に「この保留中の authorization」を表すベアラ資格情報です。user_code は短い人間が打てる文字列("BDWP-HQPK" 等)で、デバイスが画面に表示し、ユーザがスマホやノート PC で入力します。寿命は同じ(expires_in)ですが、見せる相手が完全に異なります。ユーザは device_code を見ませんし、OP は /token で user_code を受け付けません。
verification_uri と verification_uri_complete の違い
verification_uri は素の URL で、ユーザが画面表示を見て自分で訪問し user_code を手で入力する経路です — スキャンできないユーザのために画面に表示します。verification_uri_complete は同じ URL に user_code をクエリパラメータとして埋め込んだもので、QR コード化に適しています。ユーザは何も打たずに済みます。どちらも組み込み側が所有する同じページに到達します。ページ側は user_code クエリパラメータがあれば自動入力し、無ければ手入力フォームを表示します。
interval とポーリングとは
RFC 8628 では、デバイスはユーザがスマホなどで承認するのを待つあいだ、/token を繰り返し呼び出します。interval(秒)は、OP が求めるポーリング間隔の最小値です。デバイスがそれより速く呼び出すと、OP は slow_down を返して保存済みの interval を引き上げます。新しい最小値はすべてのレプリカに適用されます。既定は 5 秒です。
grant を有効化
import (
"time"
"github.com/libraz/go-oidc-provider/op"
"github.com/libraz/go-oidc-provider/op/grant"
"github.com/libraz/go-oidc-provider/op/storeadapter/inmem"
)
st := inmem.New() // DeviceCodeStore サブストアを同梱
provider, err := op.New(
op.WithIssuer("https://op.example.com"),
op.WithStore(st),
// この grant はエンドユーザを解決しますが、user store を推論できる
// PrimaryPassword がないため、明示的に渡します。
op.WithUserStore(st.Users()),
op.WithKeyset(myKeyset),
op.WithCookieKeys(myCookieKey),
// 入力デバイスにはブラウザがないため、grant.DeviceCode だけを指定して既定の
// {authorization_code, refresh_token}(LoginFlow が必要になる)を外します。
op.WithGrants(grant.DeviceCode),
// WithDeviceCodeGrant は /device_authorization を公開し、
// DeviceCodes サブストアを必須にします。
op.WithDeviceCodeGrant(op.DeviceCodeGrant{
TTL: 10 * time.Minute, // 任意。既定 10 分
PollInterval: 5 * time.Second, // 任意。既定 5 秒
}),
op.WithStaticClients(op.PublicClient{
ID: "tv-app",
RedirectURIs: nil, // device-code クライアントは /auth に来ない
GrantTypes: []string{"urn:ietf:params:oauth:grant-type:device_code"},
Scopes: []string{"openid", "profile", "offline_access"},
}),
)op.WithDeviceCodeGrant(...) がやること:
grant.DeviceCodeを grant 集合に追加(op.WithGrantsの指定に上乗せ。op.WithGrantsにも挙げても重複せず無害)/device_authorizationを設定済 endpoint パスにマウント- device-code URN(
urn:ietf:params:oauth:grant-type:device_code)を/tokenに登録 - discovery 文書に
device_authorization_endpointを出し、grant_types_supportedに URN を追加
この grant は承認時にエンドユーザを解決するため、ユーザストアが設定されていないと op.New は失敗します。op.WithUserStore か、op.WithLoginFlow 内の op.PrimaryPassword の Store のどちらかを指定してください。
device-code サブストア(store.DeviceCodeStore)は必須です。in-memory、SQL、DynamoDB の各アダプタが同梱しており、SQL アダプタは sqlite / mysql / postgres の oidc_device_codes テーブルに永続化します。Redis アダプタはこのサブストアに nil を返すため、Redis を使う構成では composite アダプタで DeviceCodes を永続的な層(SQL か in-memory)に振り分けてください。
サブストアの有無は op.New で検査
構成した store が DeviceCodes() に nil を返すと、op.New は構成エラーで失敗します。最初のポーリングで panic することはありません。op.WithDeviceCodeGrant(...) で有効にした場合も、op.WithGrants(grant.DeviceCode, ...) で有効にした場合も同じ検査が働きます。
verification ページ
/device_authorization は、ユーザが承認するページを指す verification_uri を返します。このページをライブラリはホストしません。理由は 2 つです。
- ブランドと UX: ページは既存のサインイン UI の隣に置くのが自然です。
- 不正対策: レコード単位の総当たり制限、IP のレート制限、CAPTCHA、監査対応は、組み込み側の既存の不正対策に属します。
既定の URI は <issuer>/device です。verification ページが別の場所にあるなら、op.DeviceCodeGrant の VerificationURI: "https://acme.com/connect" で上書きします(絶対 URL が必要です)。
expires_in と interval には専用のフィールド DeviceCodeGrant.TTL と DeviceCodeGrant.PollInterval があります。op.WithAccessTokenTTL とは独立しています。アクセストークンを 30 秒や 60 秒にしている構成でも、ユーザがスマホに移ってコードを入力し、承認するまでの 10 分を確保できます。
user_code は総当たりされうる
user_code は手で打てる長さでなければならないため、verification ページが試行回数を制限しないと推測されてしまいます。本ライブラリは op/devicecodekit で、この制限を同梱しています。最初の手入力では、サーバ側の ceremony key(opaque な値)を渡して VerifyUserCodeByAttemptKey を呼び、認証と同意の後に ApproveUserCode を呼んでください。入力された user_code そのものを attempt key に使ってはいけません。
提出された user_code を検証する
import (
"crypto/rand"
"encoding/base64"
"errors"
"time"
"github.com/libraz/go-oidc-provider/op/devicecodekit"
)
// deps は一度構築して保持し、すべての helper に同じ pointer を渡す。
// Deps は mutex 付き既定 limiter を持つため、使用後にコピーしてはならない。
deps := &devicecodekit.Deps{
DeviceCodes: st.DeviceCodes(),
AuditLogger: auditLogger, // 任意。helper の監査レコードを受け取る
}
// attempt key は 1 つのサーバ側 ceremony(たとえば verification ページに
// サインインしたブラウザのセッション)を識別する値。ceremony の開始時に
// 1 度だけ作り、同じ ceremony の提出ではすべて再利用する。limiter はこの
// key に対して試行を数えるので、提出のたびに作り直すと、推測のたびに
// 新しい試行枠を与えることになる。opaque な値にし、ユーザが入力した
// user_code を使ってはならない。
var rawAttemptKey [32]byte
if _, err := rand.Read(rawAttemptKey[:]); err != nil { /* 500 として処理 */ }
attemptKey := base64.RawURLEncoding.EncodeToString(rawAttemptKey[:])
// ... attemptKey を ceremony のサーバ側セッションに保存する ...
matched, err := devicecodekit.VerifyUserCodeByAttemptKey(ctx, deps, attemptKey, submittedUserCode)
switch {
case err == nil && matched:
// コード一致 — consent 画面へ進む
case errors.Is(err, devicecodekit.ErrAlreadyDecided):
// レコードはすでに承認 / 拒否済。「使用済」と表示
case errors.Is(err, devicecodekit.ErrUnknownDeviceCode), errors.Is(err, devicecodekit.ErrAttemptLocked):
// 不明 / malformed / 予算超過。どれかは表示しない
case errors.Is(err, devicecodekit.ErrAttemptLimiterSaturated):
// 組み込みの limiter が有効な key で満杯。この key への判定ではなく
// 過負荷の合図なので、少し待って再試行するよう案内する
default:
// 想定外 — log して汎用エラーを返す
}
// 認証済みユーザが要求された scope に同意した後:
if matched {
err = devicecodekit.ApproveUserCode(ctx, deps, submittedUserCode, approvedSubject, time.Now())
}ヘルパは次のように動きます。
- 提出された文字列を 正規化 します(大文字小文字の統一、ハイフンの除去)。
- 保存値と 定数時間で比較 します。
- 正規化と検索の前に opaque な attempt key の試行枠を消費するので、形式の崩れた入力や存在しない入力も、同じ上限つきの枠を消費します。不一致のたびに、
Deps.AuditLoggerが設定されていればdevice_code.verification.user_code_brute_forceの監査イベントを出します。 - 組み込みの limiter はプロセス内に閉じていて、保持数に上限があります。OP を複数インスタンスで動かす場合は、
Deps.AttemptLimiterに共有の atomic なAttemptLimiterを渡してください。読み取りと加算を別々に行う実装は安全ではありません。 - レコード単位のヘルパ(
VerifyUserCode、VerifyUserCodeByUserCode)は、レコードごとの上限devicecodekit.MaxUserCodeStrikes(5 回)をそのまま適用します。超えると、レコードは理由user_code_lockoutで Denied になります。
ユーザが打ち間違いではなく 拒否 を押した場合は、devicecodekit.DenyUserCode(ctx, deps, submittedUserCode, devicecodekit.DenyReasonUserDenied) を呼びます。device-code の id がわかっているなら devicecodekit.Revoke でも構いません。どちらも Deps.AuditLogger に監査イベントを出し、手入力の不一致としては数えません。
承認後
ユーザの認証と同意が済んだら、ハンドラは同じ *devicecodekit.Deps のポインタで ApproveUserCode を呼び、レコードを Approved にします。ストアが遷移を受理した後に、device_code.verification.approved を記録します。
// `deps` は VerifyUserCodeByAttemptKey に渡した retained pointer。
// authTime はユーザが実際に認証した壁時計時刻。token endpoint が
// id_token.auth_time に記録し(ゼロ値は claim を出さない)、
// `RequireAuthTime` を登録したクライアントはこの値で可否を判定する。
err := devicecodekit.ApproveUserCode(ctx, deps, submittedUserCode, approvedSubject, time.Now())デバイスからの次の /token ポーリングは成功します。
/device_authorization の応答
curl -s -d 'client_id=tv-app&scope=openid profile' \
https://op.example.com/oidc/device_authorization{
"device_code": "f8b2c1d4...long-opaque",
"user_code": "BDWP-HQPK",
"verification_uri": "https://op.example.com/device",
"verification_uri_complete": "https://op.example.com/device?user_code=BDWP-HQPK",
"expires_in": 600,
"interval": 5
}デバイスは user_code と verification_uri を表示します。QR コードを描画できるなら、verification_uri_complete を QR コードにするとユーザがコードを打たずに済みます。
クライアントの GrantTypes に urn:ietf:params:oauth:grant-type:device_code がないと、このエンドポイントは 400 unauthorized_client を返します。クライアントの登録済みスコープにない scope は 400 invalid_scope です。scope を省略すると、クライアントの登録済みスコープすべてが使われます。
RFC 8707 resource=
デバイスは /device_authorization に resource=<absolute URI> を付けて、発行されるアクセストークンを特定のリソースサーバに固定できます。エンドポイントは /auth、/token と同じ規則を適用します。
- 値は絶対 URI でなければなりません(RFC 8707 §2)。相対 URI は
400 invalid_targetで拒否します。 - 正規化後の値(scheme と host を小文字にし、末尾の
/を除いたもの)がクライアントのResources許可リストに含まれている必要があります。クライアントに未登録の resource は400 invalid_targetで拒否されます。OP がaudに入れる値は、登録済みのResourcesだけです。 - 空でない
resource=を複数指定すると400 invalid_targetで拒否されます。device authorization が持てる audience は 1 つだけで、OP は残りを黙って捨てないためです。
デバイスが要求しうるリソースサーバの URI は、あらかじめクライアントの Resources(クライアントのシードまたは dynamic registration のメタデータ)に追加しておきます。
送信者制約付きの device record
DPoP や mTLS を使う場合、/device_authorization は DPoP 鍵の thumbprint と mTLS 証明書の thumbprint をレコードに保存します。/token では、トークンを発行する前に、レコードにあるすべての binding を再検証します。両方の binding があるレコードでは、一致する DPoP proof と mTLS 証明書の両方が必要で、片方だけでは引き換えられません。proof がない、または一致しない場合、引き換えは失敗します。成功すると、発行されるトークンの cnf には OP が再検証したすべての binding が入ります。送信者制約を必須とするプロファイル(FAPI 2.0)では、どちらもない要求を /device_authorization 自体が拒否します。
ポーリング応答
| 通信路上の応答 | 意味 |
|---|---|
400 authorization_pending | ユーザがまだ承認していない。interval 秒後に再ポーリング |
400 slow_down | ポーリングが速すぎた。RFC 8628 §3.5 に従い、今回以降の間隔に 5 秒を加える。OP は新しい間隔を保存するので、どのレプリカでも適用される。ポーリングの記録に失敗した場合は device_code.poll_observation.failed を記録するが、ポーリング自体には応答する |
400 access_denied | ユーザが拒否した、user_code の試行制限でレコードがロックされた、devicecodekit.Revoke が呼ばれた、または間隔より速いポーリングを続けて poll-abuse の上限でレコードが拒否された。ポーリングを止める |
400 expired_token | device_code が expires_in を超えた、未知である、または引き換え済み。ポーリングを止める |
400 invalid_grant | device_code が別のクライアントのもの、またはレコードの DPoP / mTLS binding が欠けている・一致しない |
200 { access_token, ... } | 承認済み。通常のトークン応答として扱う |
ポーリングに成功すると、access_token が返ります。付与スコープに openid を含めば id_token も返ります。クライアントが refresh_token grant に登録済みで、grant 集合に refresh_token があり(既定の集合にはあります)、スコープに openid(op.WithStrictOfflineAccess を使う場合は offline_access も)を含めば refresh_token も返ります。引き換えの際に、そのクライアントとユーザの同意の記録(grant)が作られます。このためトークンは introspection や /userinfo で subject を解決でき(pairwise のクライアントなら pairwise の値)、同じスコープでの次の /auth は同意画面を省略できます。
ユーザがデバイスを削除したときの失効
ユーザがアカウント設定で「この TV を削除」を押したときは、そのデバイスに発行済みのトークンも同時に使えなくなるべきです。devicecodekit.Revoke は、authorization レコードを Denied にしたうえで、そのレコードから発行されたすべての資格情報を失効させます。
連鎖失効(cascade revocation)とは
親にあたるレコード(ここでは device authorization)を失効させるとき、そこから発行された資格情報もまとめて失効させることです。これがないと、「この TV を削除」の後も TV にはまだ使えるアクセストークンとリフレッシュトークンが残ります。OP は、device authorization から発行するすべての資格情報を、device code から導出した 1 つの grant 識別子の下に置きます。Revoke はその識別子を、渡された各ストアについてたどります。
deps := &devicecodekit.Deps{
DeviceCodes: st.DeviceCodes(),
AccessTokens: st.AccessTokens(), // JWT アクセストークンのレジストリ / フォールバック
GrantRevocations: st.GrantRevocations(), // grant の tombstone(JWT の既定の方式)
OpaqueAccessTokens: st.OpaqueAccessTokens(), // opaque アクセストークン
RefreshTokens: st.RefreshTokens(), // リフレッシュトークンのチェーン
Grants: st.Grants(), // 同意の記録を最後に削除
AuditLogger: auditLogger,
}
if err := devicecodekit.Revoke(ctx, deps, deviceCodeID, devicecodekit.DenyReasonUserRevokedDevice); err != nil {
// レコードはすでに Denied。連鎖失効は途中までの場合がある。Revoke は繰り返し呼んでも安全
}資格情報を実際に発行しているストアを渡してください。nil のフィールドに対応する失効はスキップされます。Deps.RevocationStrategy は op.WithAccessTokenRevocationStrategy と揃えます。既定の grant tombstone 方式では、GrantRevocations に tombstone を書き込みます(GrantRevocations が nil なら AccessTokens にフォールバックします)。tombstone が対象のトークンより長く残るよう、Deps.AccessTokenTTL は provider のアクセストークン TTL に合わせます。JTI レジストリ方式では AccessTokens が必須で、None 方式では JWT 用のバックエンドは不要です。選んだ方式の書き込み先がなければ、Revoke は ErrMissingRevocationBackend を返します。
Revoke は、途中のストアが失敗しても残りのストアすべてを試し、すべて成功した後にだけ grant の記録を削除します。失敗があれば、まとめたエラーを返します。device_code.revoked の監査イベントには、cascade_complete と、試行したストアごとの件数またはフラグが入ります。
動かしてみる
examples/31-device-code-cli は RFC 8628 のフルラウンドトリップを実演します:
(cd examples/31-device-code-cli && GOWORK=off go run -tags example .)OP を起動し、枠付きの user_code パネルと verification_uri_complete ショートカットを表示します。数秒後にブラウザ承認をシミュレートし、access_token + id_token が発行されるまでポーリングします。ファイルは役割別に分割(op.go / cli.go / device.go / probe.go)。
続きはこちら
- Device Code 入門 — デバイス側の視点からフローを解説
- CIBA の組み込み — ユーザが別の利用面にいるが、コード表示は合わない場合