rellf-auth に refresh token と client_credentials を実装した
自前OIDCプロバイダー (rellf-auth) に refresh token (rotation付き) と client_credentials grant を追加した。access tokenを15分に短縮し、refresh tokenで7日間セッションを維持する設計と、サービス間通信用のclient_credentialsの実装記録。
背景
rellf-auth は自前の OIDC プロバイダーで、これまで authorization_code grant のみ対応していた。トークンの有効期限は1時間固定、refresh token なし。
この状態だと:
- 1時間ごとに再ログインが必要(UX がきつい)
- access token が1時間有効(漏洩時の被害窓が広い)
- サービス間通信に OIDC を使えない(ユーザー不在のフローがない)
今回、refresh token と client_credentials を追加してこれらを解決した。
トークン設計の「なぜ」
各トークンの期限と役割の整理はOIDCトークンライフサイクルの記事にまとめた。ここでは実装判断に絞る。
access token を15分にした理由
access token は Bearer として色々な API に送られる。送信先が多い分、漏洩の機会が多い。JWT は stateless なので漏洩しても個別に revoke できない。短命にすることで被害の時間窓を狭める。
refresh token を7日にした理由
ユーザーに毎回ログインさせないため。refresh token は IdP の /oidc/token エンドポイント1箇所にしか送らない。送信先が限られる分、漏洩面が access token より狭い。
rotation する理由
refresh token が盗まれた場合の検出。使うたびに新しいトークンに差し替えるので、正規ユーザーと攻撃者の使用が競合する。
refresh token の実装
AES-GCM で stateless に
rellf-auth は Lambda 上で動くので、DB に依存しない stateless な実装にした。既存の auth code codec(AES-GCM 暗号化)と同じパターン。
type RefreshTokenPayload struct {
Sub string `json:"sub"`
Email string `json:"email"`
Groups []string `json:"groups,omitempty"`
ClientID string `json:"cid"`
Scopes []string `json:"scp"`
ExpiresAt int64 `json:"exp"`
IssuedAt int64 `json:"iat"`
}
payload を AES-GCM で暗号化し、base64url エンコードして返す。鍵は auth code とは別のもの(OIDC_REFRESH_TOKEN_KEY)。鍵の影響範囲を局所化するため。
トレードオフ
- メリット: DB 不要、Lambda のコールドスタートに影響なし、既存パターンの踏襲
- デメリット: 個別 revocation ができない。「パスワード変更時に全セッション無効化」等が必要になったら、DynamoDB で blocklist を足す
Token endpoint の変更
/oidc/token の grant_type 分岐に refresh_token を追加:
func (h *OIDCHandler) Token(c *gin.Context) {
switch c.PostForm("grant_type") {
case "authorization_code":
h.handleAuthorizationCodeGrant(c)
case "refresh_token":
h.handleRefreshTokenGrant(c)
case "client_credentials":
h.handleClientCredentialsGrant(c)
default:
c.JSON(http.StatusBadRequest, gin.H{"error": "unsupported_grant_type"})
}
}
refresh token grant では:
- AES-GCM で復号して payload を取得
client_idの一致を検証- クライアント認証(confidential client なら secret 検証)
- 新しい access_token + id_token + 新しい refresh_token(rotation)を発行
client_credentials の実装
サービス間通信用。設計の詳細は Client Credentials でサービス間通信を認証するにまとめてある。
今回の実装はシンプル:
func (h *OIDCHandler) handleClientCredentialsGrant(c *gin.Context) {
clientID := c.PostForm("client_id")
clientSecret := c.PostForm("client_secret")
scope := c.PostForm("scope")
client, err := h.clients.ValidateSecret(clientID, clientSecret)
if err != nil {
c.JSON(401, gin.H{"error": "invalid_client"})
return
}
if client.IsPublic() {
c.JSON(400, gin.H{"error": "unauthorized_client",
"error_description": "public clients cannot use client_credentials"})
return
}
accessToken, _ := h.issuer.SignAccessToken(clientID, scopes, clientID)
c.JSON(200, gin.H{
"access_token": accessToken,
"token_type": "Bearer",
"expires_in": 900,
})
}
ポイント:
- public client は拒否(secret がないので当然)
- id_token は発行しない(ユーザーがいないので)
- refresh_token も発行しない(サービスは credential で再取得すればいい)
- sub = client_id(ユーザーではなくサービス自体の識別子)
各フローの違いと使い分けは OAuth 2.0 / OIDC のフロー全比較を参照。
現在の grant type 対応状況
| grant type | 用途 | 返すトークン |
|---|---|---|
authorization_code | ユーザーありのログイン (PKCE 対応) | access + id + refresh |
refresh_token | access token の更新 (rotation) | access + id + refresh(新) |
client_credentials | サービス間通信 (ユーザー不在) | access のみ |
次にやること
- rellf-auth 自身の UI(pages / admin)を authorization code flow に統一し、生トークンをブラウザに出さない構成にする
- scope ベースのアクセス制御(既存記事で設計したスコープ設計の実装)
- 必要に応じて refresh token の blocklist(DynamoDB)を追加