ツール

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 本しか無いこと」を確認するのが確実です。