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 なし。

この状態だと:

今回、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)。鍵の影響範囲を局所化するため。

トレードオフ

Token endpoint の変更

/oidc/tokengrant_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 では:

  1. AES-GCM で復号して payload を取得
  2. client_id の一致を検証
  3. クライアント認証(confidential client なら secret 検証)
  4. 新しい 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,
    })
}

ポイント:

各フローの違いと使い分けは OAuth 2.0 / OIDC のフロー全比較を参照。

現在の grant type 対応状況

grant type用途返すトークン
authorization_codeユーザーありのログイン (PKCE 対応)access + id + refresh
refresh_tokenaccess token の更新 (rotation)access + id + refresh(新)
client_credentialsサービス間通信 (ユーザー不在)access のみ

次にやること

← Back to all posts