key($code), ['userId' => $userId, 'challenge' => $codeChallenge], self::TTL_SECONDS ); return $code; } /** * Non-destructive read of a code's payload. Callers verify PKCE against * the returned challenge FIRST, then call consumeCode() to burn the code. * This prevents a scheme-hijacker from DoSing legit logins by submitting * an intercepted code with a bad verifier (which would delete the code * before the real client's exchange arrived). */ public function peekCode(string $rawCode): ?array { $data = Cache::get($this->key($rawCode)); if (! is_array($data) || ! isset($data['userId'])) { return null; } return [ 'userId' => (int) $data['userId'], 'challenge' => $data['challenge'] ?? null, ]; } /** * Burn the code once and report whether THIS caller consumed it. * * The read-and-delete is guarded by a non-blocking cache lock keyed on the * code: of two concurrent exchanges that both called peekCode() on it, only * the lock holder performs the get()+forget() and can return true — so at * most one exchange mints from a single-use code. A caller that can't take * the lock (a concurrent consume is in flight) gets false and must not mint; * so does an already-consumed or unknown code. * * (A bare Cache::pull() is NOT used precisely because it is get()+forget(), * not a single atomic op on any driver — two callers could both read the code * before either deletes it, and both mint. The lock closes that window.) */ public function consumeCode(string $rawCode): bool { $key = $this->key($rawCode); // Fail CLOSED: without atomic locks we cannot guarantee single-use, so // refuse rather than fall back to a racy non-atomic consume (which would // reintroduce the double-mint window this method exists to prevent). All // default Leantime stores implement LockProvider; a store here that // doesn't is a misconfiguration worth surfacing loudly, not degrading to. if (! Cache::getStore() instanceof LockProvider) { Log::error('OidcMobileCode: cache store does not support atomic locks; refusing mobile SSO code consume. Configure a lock-capable cache store (file/redis/memcached/database).'); return false; } $lock = Cache::lock($key.'.lock', self::LOCK_SECONDS); // Non-blocking: the loser of a concurrent consume gets false immediately // instead of waiting, and its exchange is rejected. if (! $lock->get()) { return false; } try { $existed = Cache::get($key) !== null; Cache::forget($key); return $existed; } finally { $lock->release(); } } private function key(string $rawCode): string { return self::KEY_PREFIX.hash('sha256', $rawCode); } }