Web Server FlowでRefresh Tokenを使い倒そうとして後悔した開発者の記録

OAuth 2.0 で実際にやらかした判断:Web Server FlowとRefresh Tokenの過信

Salesforce 開発者として、外部システムと連携する際にOAuth 2.0は避けて通れない道だ。 あるプロジェクトで、外部のWebアプリケーションからSalesforceのデータにアクセスする要件があった。 当時の私は、一般的なWebアプリケーションの認証フローとして最も馴染みのある「Web Server Flow」を選択した。

「Refresh Tokenさえあれば大丈夫」という甘い見通し

ユーザーが一度認証すれば、その後のアクセスはRefresh Tokenを使ってアクセストークンを再取得すればよい、という考えだった。 実装もそこまで複雑ではない。ユーザーが初回アクセス時にSalesforceの認証画面にリダイレクトされ、認証が成功すれば`code`が返ってくる。 その`code`を使ってアクセストークンとRefresh Tokenを取得し、Refresh Tokenは外部アプリケーションのデータベースに保存しておけば、次回以降のアクセスは自動化できる。 「これでユーザーは再度認証する必要がないし、セキュリティも担保されている」と、当時の私は胸を張って設計書に書いた。

// 当時の擬似コード:アクセストークン取得部分
public async Task<TokenResponse> GetTokens(string code)
{
    var client = new HttpClient();
    var content = new FormUrlEncodedContent(new[]
    {
        new KeyValuePair<string, string>("grant_type", "authorization_code"),
        new KeyValuePair<string, string>("client_id", _clientId),
        new KeyValuePair<string, string>("client_secret", _clientSecret),
        new KeyValuePair<string, string>("redirect_uri", _redirectUri),
        new KeyValuePair<string, string>("code", code)
    });
    var response = await client.PostAsync("https://login.salesforce.com/services/oauth2/token", content);
    response.EnsureSuccessStatusCode();
    var jsonString = await response.Content.ReadAsStringAsync();
    var tokenResponse = JsonConvert.DeserializeObject<TokenResponse>(jsonString);

    // Refresh TokenをDBに保存
    _tokenRepository.SaveRefreshToken(tokenResponse.refresh_token); 

    return tokenResponse;
}

// アクセストークン更新部分 (当時はこのコードで全て解決できると思っていた)
public async Task<string> RefreshAccessToken(string refreshToken)
{
    var client = new HttpClient();
    var content = new FormUrlEncodedContent(new[]
    {
        new KeyValuePair<string, string>("grant_type", "refresh_token"),
        new KeyValuePair<string, string>("client_id", _clientId),
        new KeyValuePair<string, string>("client_secret", _clientSecret),
        new KeyValuePair<string, string>("refresh_token", refreshToken)
    });
    var response = await client.PostAsync("https://login.salesforce.com/services/oauth2/token", content);
    response.EnsureSuccessStatusCode();
    var jsonString = await response.Content.ReadAsStringAsync();
    var tokenResponse = JsonConvert.DeserializeObject<TokenResponse>(jsonString);
    return tokenResponse.access_token;
}

この考えで、Refresh Tokenをデータベースに永続化し、期限切れのアクセストークンが返されたらRefresh Tokenを使って再取得するというロジックを組んだ。 しかし、この設計が後に大きな問題を生むことになった。

後から「やらなければよかった」と思った設計:Refresh Tokenの不意な失効

数ヶ月後、プロダクション環境で外部アプリケーションからのSalesforce連携が突如として動作しなくなる事態が発生した。 ログを見ると、「invalid_grant: expired access/refresh token」というエラーが散見される。 Refresh Tokenが失効していたのだ。

当時の私は、Refresh Tokenは基本的に永続的なものだという誤った認識を持っていた。 SalesforceのConnected App設定を確認すると、Refresh Tokenには「Refresh Tokenポリシー」というものがある。

  • Refresh Tokenはすぐに期限切れになる: 有効期限が設定されている場合。
  • Refresh Tokenを再利用すると失効する: 「Refresh Tokenの再利用で直ちに新しいRefresh Tokenを発行」という設定の場合、古いRefresh Tokenは一度使うと失効する。新しいRefresh Tokenをきちんと保存し直すロジックが必要だった。
  • Connected Appの編集: Connected Appを編集・保存しただけでRefresh Tokenが失効するケースがあった(これは当時のSalesforceの挙動だったかもしれないし、私の知識不足だったかもしれない。今ではそうはならないと信じたいが、過去の経験から疑心暗鬼になっている)。
  • ユーザーのパスワード変更: ユーザーがSalesforceのパスワードを変更すると、それに紐づくRefresh Tokenも失効する。
  • 管理者によるセッションの強制終了: Salesforce管理者がユーザーセッションを終了させると、Refresh Tokenも失効する。

特に致命的だったのは、Refresh Tokenを再利用するたびに新しいRefresh Tokenが発行される設定になっていたことだ。 私の実装では、古いRefresh Tokenを使い回そうとしていたため、一度トークンを更新すると、次にRefresh Tokenを使おうとしたときに古いトークンが失効済みでエラーになる、という地獄のようなループに陥っていた。

// 今ならこうは書かない。新しいRefresh Tokenが返されたら必ず保存し直す必要がある。
public async Task<string> RefreshAccessToken_Revised(string oldRefreshToken)
{
    var client = new HttpClient();
    var content = new FormUrlEncodedContent(new[]
    {
        new KeyValuePair<string, string>("grant_type", "refresh_token"),
        new KeyValuePair<string, string>("client_id", _clientId),
        new KeyValuePair<string, string>("client_secret", _clientSecret),
        new KeyValuePair<string, string>("refresh_token", oldRefreshToken)
    });
    var response = await client.PostAsync("https://login.salesforce.com/services/oauth2/token", content);
    response.EnsureSuccessStatusCode();
    var jsonString = await response.Content.ReadAsStringAsync();
    var tokenResponse = JsonConvert.DeserializeObject<TokenResponse>(jsonString);

    // !!! ここが重要だった !!! 新しいRefresh Tokenが返される可能性があるので、必ず更新する
    if (!string.IsNullOrEmpty(tokenResponse.refresh_token)) 
    {
        _tokenRepository.SaveRefreshToken(tokenResponse.refresh_token); 
    }
    else
    {
        // Refresh Tokenが返されない場合もあるが、このケースは複雑なので別途考慮が必要だった
        // ⚠️ 公式ドキュメント確認が必要
    }
    
    return tokenResponse.access_token;
}

この問題は、一度Refresh Tokenが失効すると、ユーザーが再度Webアプリケーション経由でSalesforceに認証し直すまで解決しない。 自動で連携しているはずのバッチ処理や、サーバー間連携も停止し、手動での再認証を促すという運用が非常に煩雑だった。

「今なら別の選択をする」:JWT Bearer FlowまたはClient Credentials Flow

もし同じ要件で今設計するなら、Web Server FlowでRefresh Tokenを永続化する設計はまず選ばないだろう。

  • サーバー間連携やバッチ処理が主体の場合は、JWT Bearer Flow

    ユーザーインタラクションなしでアプリケーションがSalesforceにアクセスしたい場合、JWT Bearer Flowこそが最適解だった。 事前に証明書をアップロードし、秘密鍵でJWTを署名してアクセストークンを取得するこのフローなら、Refresh Tokenの管理という複雑な問題から解放される。 当時、JWT Bearer Flowの存在は知っていたが、「証明書の管理が面倒そう」「実装が複雑そう」という漠然とした理由で避け、より馴染みのあるWeb Server Flowに逃げてしまった。 しかし、Refresh Tokenの失効問題と比べれば、証明書管理の方が遥かに安定しており、運用の予測可能性も高い。

  • もっとシンプルなマシンツーマシン連携ならClient Credentials Flow (カスタム)

    Salesforceには標準のClient Credentials Flowは存在しないが、同様の用途であれば、専用の「Connected App」を作成し、ユーザーアカウントを一つ用意して、そのユーザーとしてJWT Bearer Flowを使うか、またはユーザーインタラクションの発生しないタイミングでWeb Server Flowを使って一度Refresh Tokenを取得し、それを厳重に管理するという方法を採るだろう。 いや、やっぱり後者は危険だ。やはりJWT Bearer Flowを強く推奨する。

特に、アクセストークンとRefresh TokenのペアをDBに保存するという行為自体が、セキュリティ上も運用上もリスクの高い判断だった。 DBの暗号化や、Refresh Tokenを扱うロジックの堅牢性を確保するコストも相当なものだった。 当時は「OAuth 2.0フローの中から選べば良い」くらいの感覚だったが、それぞれのフローが持つ特性と、それに伴う運用の複雑さを深く理解していなかったのが敗因だ。


これは当時の自分向けのメモだ。 安易にRefresh Tokenを信用するな。そして、Salesforceの公式ドキュメントは隅々まで読め。特に「Connected App」「Refresh Tokenポリシー」の項目は。

コメント