OAuth の refresh_token 回転(古いトークンを失効させて新しいペアを発行する rotation)を素直に「SELECT で有効性確認 → UPDATE で失効」で書くと、並行リクエストが 2 つ来た時に両方成功してトークンが 2 本発行されます。解決は SELECT を挟まず、UPDATE の WHERE 条件に「まだ失効していないこと」を含めて、影響行数が 1 でなければ後続処理を中止する compare-and-swap の書き方です。
SELECT で確認してから UPDATE する構造は必ず衝突する
典型的な実装は次のような流れになります。
$old = $repo->findValidByRefreshToken($refreshToken);
if (!$old) return null;
// 外部 IdP に refresh 依頼
$newTokens = $idp->refresh($old->getIdpRefreshToken());
// 古いのを失効
$old->setRevokedAt(new \DateTime());
// 新しいのを保存
$new = $this->issueToken($old->getCustomer(), $newTokens);
$em->flush();
return $new;
この構造では、findValidByRefreshToken と setRevokedAt の間にトランザクション境界が無いため、同じ refresh_token を持つ 2 つのリクエストが並列に走ると両方が「まだ失効していない」と読み取り、両方が外部 IdP へリクエストを送り、両方が新しいトークンを発行します。クライアント側は片方しか把握できないので、もう一方は「孤立したまま有効な」トークンとしてサーバに残ります。
UPDATE の WHERE で compare-and-swap する
修正のポイントは失効操作を SELECT で分けず、単一の UPDATE 文で「revoked_at がまだ NULL なら失効する」という条件付き更新にする点です。データベースレベルで排他されるので、並行で来ても片方しか成功しません。Doctrine ORM なら次のように書けます。
$affected = $em->createQueryBuilder()
->update(AuthToken::class, 't')
->set('t.revoked_at', ':now')
->where('t.id = :id')
->andWhere('t.revoked_at IS NULL')
->setParameter('now', new \DateTime())
->setParameter('id', $old->getId())
->getQuery()
->execute();
if ($affected !== 1) {
// 他リクエストが先に失効させた: 新トークンは巻き戻す
$em->remove($new);
$em->flush();
return null;
}
affected は更新した行数です。1 でない(=既に revoked_at が入っていた)なら、他リクエストが先に更新を通しており、こちらは重複した処理になっているので、既に発行してしまった新トークンを削除して null を返します。この時点で外部 IdP へのリクエストは既に送ってしまっていますが、IdP 側でも rotation されるだけなので害はありません。
順序も入れ替える
もう一つ大事なのは「先に失効 → 後に発行」だと、発行に失敗した時に古いトークンだけが失効して、クライアントは何も持たない状態になる点です。順序は「外部 IdP に refresh を依頼 → 新トークンを persist → 最後に古いのを CAS で失効」に組み替えます。失効の CAS で先を越された場合だけ新トークンを消せば整合します。
SELECT … FOR UPDATE は必要ない
この対策は MySQL / PostgreSQL の UPDATE ... WHERE がアトミックであることに依存しています。SELECT ... FOR UPDATE を使う方針でもいいですが、トランザクションの範囲管理が要るので、単一 UPDATE の compare-and-swap の方がコード量が少なく壊れにくい書き方になります。実装時のテストは「同じ refresh_token で並行して 5 回リクエストを送り、DB 上の有効トークンが常に 1 本しか無いこと」を確認するのが確実です。