リフレッシュトークン
リフレッシュトークン は、RP が再認証なしに新しいアクセストークンを得るために交換する長寿命の資格情報です。「ログイン状態の維持」はこの仕組みで実現されます。アクセストークンは短命のまま、リフレッシュトークンがそれを裏で入れ替え続けます。
このページで触れる仕様
- RFC 6749 — OAuth 2.0 Authorization Framework(§6 refresh)
- RFC 9700 — OAuth 2.0 Security Best Current Practice(ローテーション・再利用検知)
- OpenID Connect Core 1.0 — §11(
offline_access)
用語の補足
- ローテーション(rotation) — リフレッシュトークン交換が成功するたび、古いトークンを無効化して新しいトークンを発行します。古→新のペアは、同じログインに連なる 連鎖 を形成します。
- 再利用検知(reuse detection) — 既にローテーション済みのリフレッシュトークンが再提示されたら、OP は盗難シグナルとして扱い、連鎖全体を無効化します。下の警告セクションを参照。
- 猶予期間 — ローテーション直後の短い期間です。再試行では連鎖をもう一度ローテーションせず、同じ後継リフレッシュトークンを返します。送信者制約付きの場合、アクセストークンは再発行されることがあります。
offline_accessscope — OIDC が定める「ユーザがその場にいなくてもアプリに動き続けてほしい」を表明する scope。既定では offline TTL バケットの選択に使われ、op.WithStrictOfflineAccess()を渡すと発行条件にもなります。
ローテーションのしくみ
grant_type=refresh_token が成功するたびに、リフレッシュトークンは ローテーション します — 古いトークンは無効化され、新しいトークンが返されます。例外は FAPI 2.0 Final のプロファイルで、ローテーションせず、提示したリフレッシュトークンは有効なままです(猶予期間を参照)。
再利用検知は連鎖全体を無効化
すでにローテーション済みのリフレッシュトークンが再提示されると、OP は 400 invalid_grant を返し、refresh.replay_detected を記録したうえで、「資格情報が盗まれた」シグナルとして 連鎖全体を失効させます — 盗まれたトークンも、それを起点に発行された正規のトークンも、両方とも無効になります。盗んだ側が引き換えたアクセストークンを持っている可能性があるため、grant 配下のアクセストークンも失効させます。両者とも再認証が必要です。
これは意図的な挙動です — OP が出せる「何かがおかしい」という最強のシグナルだからです。
対象は grant 全体です。grant は 1 人のユーザが 1 つのクライアントで開いた全セッションで共有されます。既定の grant tombstone 戦略では grant への新規発行が止まるため、同じクライアント上のそのユーザの別の連鎖も、次のリフレッシュで拒否されます。この処理は invalid_grant の応答を確定させたあとに走り、ベストエフォートです。store の障害は warn レベルの監査イベントとして記録されますが、クライアントに返した応答は変わりません。
ローテーション / 再利用検知 / 連鎖失効とは
ブログ記事ではしばしば同じ意味で使われる 3 つの用語ですが、本ライブラリでは別物です。
- ローテーション — 正常系 の成功経路。
grant_type=refresh_tokenが成功するたびに新しいリフレッシュトークンを返し、前のトークンを無効化します。既定は single-use。 - 再利用検知 — すでにローテーション済みのリフレッシュトークンが再度送られてきた状態。漏洩、マルウェア、混乱したクライアントによる複製のいずれかでしか起こり得ません。本ライブラリは盗難として扱います。
- 連鎖失効(family revocation とも) — 再利用検知への応答。問題のトークンと同じ系統に属するリフレッシュトークン全部が無効化されます — 正規クライアントが今使っている子孫トークンも含めて。次の正規リフレッシュは失敗し、ユーザは再認証することになり、攻撃者の盗難トークンも死にます。OP は親ポインタをたどって連鎖の根から失効させ、根が見つからない場合は grant のリフレッシュトークンをまとめて失効させます。
RFC 9700 §4.14.2 は、public クライアントのリフレッシュトークンについて、送信者制約またはローテーションによる再利用検知を要求します。ローテーションで再利用を検知した場合は、現在有効なリフレッシュトークンを失効させると説明しています。本ライブラリは、クライアント種別を問わず同じ連鎖全体を無効にします。
猶予期間
正規クライアントが競合状態(同じリフレッシュトークンを 2 回フェッチしてしまった、など)に陥ったとき、本来なら再利用検知に引っかかってしまいます。op.WithRefreshGracePeriod(d) でローテーション後の猶予期間を調整できます。
op.WithRefreshGracePeriod(2 * time.Second)ローテーション成功から d 秒以内なら、正当な再試行で同じ後継リフレッシュトークンを回収できます。送信者制約付きのリクエストでは、OP が再試行時に提示された鍵または証明書へ結び付けたアクセストークンを新たに発行することがあります。d 秒を過ぎた再提示は再利用として扱います。
再試行が成立するのは、ローテーション時に後継トークンと並べて、cookie key(op.WithCookieKeys)で封印した応答を保存しているからです。同梱の inmem / sql / dynamodb アダプタは、そのためのオプションのインターフェース store.RefreshRetryResponseStore を実装しています。ストアを自前実装して実装しなければ、再生できる応答がないので、猶予期間内の再試行も invalid_grant になります。
猶予期間中の再試行
OP は猶予期間中にリフレッシュトークンの連鎖を再度ローテーションせず、保存済みの後継リフレッシュトークンを返します。Bearer の応答はそのまま再送できます。DPoP または mTLS を使う場合は、同じ後継リフレッシュトークンと ID トークンを保ち、再試行で提示された送信者制約に合わせてアクセストークンを再発行します。保存済みの再試行用応答がない場合は、後継トークンを失効させずに invalid_grant を返します。猶予期間後に古いトークンを提示すると、再利用検知と連鎖の失効が起きます。期間を 0 にすると再試行を受け付けず、応答の取りこぼしを再利用と判定する可能性が高まります。
既定は 60 秒
WithRefreshGracePeriod を渡さない場合の猶予期間は 60 秒(refresh.GraceTTLDefault)です。op.WithRefreshGracePeriod(0) で猶予期間を完全無効化(厳密な single-use)し、0 以上の値で猶予期間を明示設定できます。負値は構築時に拒否されます。
OFCS のリフレッシュトークン回帰テストはローテーションとリトライの間に約 32 秒待つため、それ以下の猶予期間に縮めると適合性が後退します。
profile.FAPI2Baseline と profile.FAPI2MessageSigning は、FAPI 2.0 Security Profile §5.3.2.1(9) に従ってリフレッシュトークンをローテーションしないため、猶予期間そのものがありません。リフレッシュ応答は新しいアクセストークンを返し、refresh_token は含みません。提示したリフレッシュトークンは、期限が切れるか失効するまで有効なままです。op.WithRefreshGracePeriod を明示すると、値にかかわらず op.New が拒否します。profile.FAPICIBA はローテーションを続け、より長い猶予期間も設定できます。
// NG: FAPI 2.0 プロファイルには設定すべきローテーションの猶予窓がない
op.New(
op.WithProfile(profile.FAPI2Baseline),
op.WithRefreshGracePeriod(60*time.Second),
)ローテーション時に現在のクライアント登録 scope を再検証する
リフレッシュトークンは連鎖の開始時に付与された scope を記録しますが、ローテーションが後から変更されたクライアント登録を迂回することはありません。提示されたトークンを消費する前に、OP は要求された scope とトークンに束縛された scope の積集合を実効 scope として求めます(要求を省略した場合は束縛済み scope を再利用します)。その実効 scope と現在のクライアントの Scopes 登録との積集合が実効 scope と一致するかを再検証し、scope ごとの AllowedClients ポリシーも検証します。発行対象の scope が現在の登録に含まれなくなっていれば /token は invalid_scope を返し、提示されたトークンは未消費のまま残ります。これにより、クライアント登録を狭めても、既存の連鎖が削除済み scope を発行し続けることはありません。
要求でより狭い scope を指定した場合に狭まるのは、そのとき受け取るアクセストークンだけです。ローテーション後のリフレッシュトークンは、置き換えた元のトークンの scope をそのまま引き継ぐので、次のリフレッシュでは再び全 scope を要求できます。
TTL バケット
| Option | 既定 | 適用範囲 |
|---|---|---|
op.WithRefreshTokenTTL(d) | 30 日 | 通常のリフレッシュトークン。 |
op.WithRefreshTokenOfflineTTL(d) | WithRefreshTokenTTL を継承 | offline_access scope で発行されたリフレッシュトークン。 |
バケットを分けることで、offline_access(ログイン状態の維持)には長寿命を持たせつつ、通常のリフレッシュトークンは短いローテーション間隔を維持できます。
発行の判定
既定では、リフレッシュトークンの発行には次の 3 条件が必要です。
- OP が
refresh_tokenグラントを有効にしている。 - クライアントの
GrantTypesにrefresh_tokenが含まれている。 - 付与された scope に
openidが含まれている。
条件を満たさない場合でも、認可コードの交換はアクセストークンを返して成功できますが、リフレッシュトークンは付きません。ID トークンが返るのは、付与された scope に openid が含まれる場合だけです。op.WithOpenIDScopeOptional() を使う OAuth 専用の構成では openid 条件が外れ、op.WithStrictOfflineAccess() を使う構成では offline_access 条件が加わります。この 2 つのオプションは併用できません。
OIDC Core 1.0 §11 の既定(緩やかな)解釈では、offline_access は 発行の判定 ではありません。同意画面の内容とリフレッシュトークンの寿命バケット(WithRefreshTokenTTL か WithRefreshTokenOfflineTTL)を切り替えるだけです。offline_access を発行条件にしたい場合は op.WithStrictOfflineAccess() を明示してください — 次のセクションを参照。
op.WithStrictOfflineAccess — OIDC Core §11 の厳格解釈
op.WithStrictOfflineAccess() を渡すと、発行とリフレッシュ交換の両方が §11 の厳格解釈に切り替わります — リフレッシュトークンは、付与された scope に offline_access が含まれているときに限り発行 / 受理されます。同意画面の内容と発行判定をビット単位で揃えたいときに選んでください。代償として、ログイン状態を維持したい RP はすべて明示的に offline_access を要求する必要があります。
このオプションは op.WithOpenIDScopeOptional と排他です(openid 自体が任意な構成では §11 に意味がないため、両方を同時指定すると op.New が拒否します)。
認証コンテキストはローテーションをまたいで保持される
リフレッシュトークンは、subject と scope だけでなく、自身を発行した認可のコンテキストを保持します。リフレッシュ交換が新しい id_token やアクセストークンを発行するとき、OP はリフレッシュ時点ではなく、ユーザが実際に認証したときのコンテキスト — auth_time、acr、amr、付与された authorization_details、OIDC の claims リクエスト — を再現します。そのため、ログイン時に acr_values=aal2 を要求した RP は、1 週間バックグラウンドでリフレッシュを続けたあとでも acr がその強度を反映したままになり、ステップアップの鮮度シグナルがローテーションのたびに知らぬ間にリセットされることもありません。
このコンテキストは、1 人のユーザが 1 つのクライアントで開いた全セッションで共有される grant ではなく、連鎖に属します。同じユーザが同じクライアントで 2 回目にログインしても、既存の連鎖が報告する内容は変わりません。リフレッシュレコードはこれらのフィールド(トークンの origin も含む)を永続化します。ストアを自前実装してこれらを保存しない場合は、連鎖が grant の値を読む動作に戻り、grant を共有することによる挙動になります。
送信者制約付きの連鎖
DPoP の proof やクライアント証明書を提示したクライアントに発行した連鎖は、その鍵または証明書に結び付くことがあります(DPoP、mTLS を参照)。結び付いた連鎖は、proof や証明書がない、あるいは別のものを提示したリフレッシュを invalid_grant で拒否します。バインドは現在の設定とも照合されます。連鎖の発行後に DPoP や mTLS を無効にした場合、結び付いた連鎖は bearer に格下げされずに拒否されます。
連鎖の失効
リフレッシュトークンに対する /revoke(RFC 7009)は、その連鎖と grant 配下のアクセストークンを失効させ、それ以外には触れません。同じクライアント上のそのユーザの別の連鎖と、まだ引き換えていない認可コードは有効なままです。長い連鎖でも連鎖の根は見つかります。根が見つからなければ grant のリフレッシュトークンを失効させ、token.revoked の surface は refresh_chain ではなく refresh_grant になります。別のクライアントに発行されたリフレッシュトークンは無視され、応答はどちらの場合も 200 です。
ユーザが削除またはプロビジョニング解除されると、次のリフレッシュは invalid_grant で失敗し、OP は grant を破棄します。
保存形式: ハッシュ化・定数時間
リフレッシュトークンのハンドルは opaque な bearer secret であり、所持しているだけで行使できます。OP は提示された値そのものを保存しません — ハッシュを保存し、Find / Consume では提示された値をハッシュ化して digest を引き、定数時間で比較します。これにより公開ストアの参照はハッシュのみ・タイミング非依存の形に保たれ、ストア漏洩とタイミングサイドチャネルの双方に対して堅牢になります。内部の再利用検出では、公開参照をハッシュのみに保つため、別の RefreshChainResolver 経路で保存ハンドルを解決します。ストアを自前実装する場合は、契約を満たすためにハッシュ化した id を永続化する必要があります。
監査ログ
token endpoint は op.WithAuditLogger 経由で次の slog 監査イベントを発行します。
| イベント | 発火タイミング |
|---|---|
audit.TokenIssued(token.issued) | authorization_code 交換時にリフレッシュトークンを発行したとき。 |
audit.TokenRefreshed(token.refreshed) | refresh_token grant でリフレッシュトークンをローテーションしたとき。ローテーションが永続化されてから記録します。同時提示に負けた、またはロールバックされたリフレッシュは記録しません。 |
audit.RefreshReplayDetected(refresh.replay_detected) | ローテーション済みのトークンが再提示された(再利用検出)とき。ベストエフォートの連鎖失効より前に発行されます。 |
audit.TokenRevoked(token.revoked) | /revoke がリフレッシュの連鎖を失効させたとき。surface は refresh_chain、フォールバックした場合は refresh_grant。 |
発行とローテーションの記録は extras に offline_access(boolean)と ttl_bucket("offline" または "default")を持つので、SOC ダッシュボードは scope を再読することなく「ログイン状態の維持」の連鎖と通常のローテーションを区別できます。
続きはこちら
- ID トークン / アクセストークン / userinfo — それぞれのトークンが実際に持つ中身。
- 送信者制約 — アクセストークン(とリフレッシュトークン)をクライアント保有の鍵に結び付ける。
- アクセストークンの形式 — JWT と opaque で、ローテーションが旧アクセストークンをどう扱うか。