Skip to content

fix: stop caching the QPay access token past its real expiry - #99

Merged
roncodes merged 1 commit into
release/v0.4.21from
fix/qpay-auth-token-expiry
Sep 4, 2026
Merged

fix: stop caching the QPay access token past its real expiry#99
roncodes merged 1 commit into
release/v0.4.21from
fix/qpay-auth-token-expiry

Conversation

@roncodes

@roncodes roncodes commented Sep 4, 2026

Copy link
Copy Markdown
Member

Impact

QPay checkout is broken in production. /storefront/v1/checkouts/before?...&gateway=qpay returns an invoice payload of:

{"invoice":{"error":"NO_CREDENTIALS","message":"Хандах эрхгүй байна. Нэвтрэнэ үү."}, ...}

The credentials are valid — authenticating directly against POST https://merchant.qpay.mn/v2/auth/token with the same username/password succeeds and returns a token.

Cause

Introduced by me in #90 (ee672a4), as part of an efficiency cleanup that added access-token caching to QPay::setAuthToken().

QPay returns expires_in as an absolute UNIX timestamp, not a lifetime in seconds. A live response carries 1788603222 = 2026-09-05T10:13:42Z, roughly 24 hours out. The shipped code read it as a duration:

Cache::put($cacheKey, $token, $expiresIn - 60);   // 1,788,603,162 seconds ≈ 56.7 years

So the first token minted after deploy was cached effectively forever. About a day later QPay expired it server-side, and from then on every storefront request sent Authorization: Bearer <dead token> and got NO_CREDENTIALS back. Nothing re-mints, so it stays broken.

Everything that authenticates to QPay is affected: checkout invoice creation, the capture-qpay callback, checkout status polling, and the eBarimt receipt path in OrderController. Stripe and cash checkout are unaffected.

Why CI did not catch it

The test that shipped alongside the caching mocked {"access_token":"cached-token","expires_in":3600} — a duration, i.e. the same wrong assumption the code made. The mock agreed with the bug, so it passed with full coverage.

Fix

Mint a fresh token on every authentication, as it worked before the caching was added. That is one extra round trip per QPay call — exactly what the caching saved — which is not a trade worth making on a payment path.

Reintroducing reuse later requires deriving the TTL from the epoch (expires_in - time() - 60), clamping it, and keying on the credentials so a password rotation invalidates the entry.

The misleading test is replaced with regression coverage that uses a real epoch-shaped response and asserts each authentication re-mints a token, plus a case for a token response that carries no access_token.

Immediate mitigation (no deploy needed)

Dropping the cached entry makes the next request re-authenticate and restores checkout right away — but only for ~24h, until the token expires again. This PR is the durable fix.

php artisan cache:forget storefront:qpay:token:49e3925abefe7da55ad156054a6c12a4

(That key is md5('https://merchant.qpay.mn/v2/|OLIMAX_APP'); a different merchant username hashes differently.)

Validation

  • server/tests/Unit/Support/QPayTest.php — 16 tests, 115 assertions, 0 failures
  • Full backend suite green; clover baseline 7235/7235 statements, 100%, no uncovered lines
  • php-cs-fixer --dry-run clean on both changed files

QPay returns `expires_in` on /v2/auth/token as an ABSOLUTE UNIX
TIMESTAMP, not a lifetime in seconds — a live response carries
1788603222, which is 2026-09-05T10:13:42Z, roughly a day out.

setAuthToken() read it as a duration and cached the token for
`expires_in - 60` SECONDS: 1,788,603,162s, about 56.7 years. The first
token minted after deploy was therefore pinned in the cache
permanently, and once QPay expired it server-side (~24h later) every
storefront call kept sending that dead bearer token. QPay answered
NO_CREDENTIALS / "Хандах эрхгүй байна. Нэвтрэнэ үү." for correct
credentials, which broke checkout invoice creation, the capture-qpay
callback, checkout status polling, and the ebarimt receipt path.

Mint a fresh token per authentication, as before the caching was added.
That costs one round trip per QPay call, which is what the caching was
saving — not a trade worth making on a payment path. Reintroducing reuse
requires deriving the TTL from the epoch and keying on the credentials
so rotation invalidates it.

The test added with the caching mocked expires_in as 3600 — a duration,
the same wrong assumption as the code — so it agreed with the bug and
passed. It is replaced with regression coverage that uses a real
epoch-shaped response and asserts each authentication re-mints.
@codecov

codecov Bot commented Sep 4, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 100.00%. Comparing base (7f0ba99) to head (6fa4cda).

Additional details and impacted files
@@             Coverage Diff             @@
##                main       #99   +/-   ##
===========================================
  Coverage     100.00%   100.00%           
+ Complexity      1775      1772    -3     
===========================================
  Files            135       135           
  Lines           7785      7778    -7     
===========================================
- Hits            7785      7778    -7     
Flag Coverage Δ
backend 100.00% <100.00%> (ø)

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@roncodes roncodes mentioned this pull request Sep 4, 2026
@roncodes
roncodes changed the base branch from main to release/v0.4.21 September 4, 2026 10:40
roncodes added a commit that referenced this pull request Sep 4, 2026
Cuts the patch release carrying the QPay authentication fix (#99) and the
network model repairs and testing seeder split (#98).

The release/v* workflow gate is deliberately NOT changed here — #97 owns
that, with fleetbase/fleetbase#641 behind it. Both must land before this
branch is merged, or the tag job either skips (old gate) or is refused by
the reusable workflow (gate updated, reusable workflow not yet).
@roncodes
roncodes merged commit 3ee3da9 into release/v0.4.21 Sep 4, 2026
10 of 11 checks passed
@roncodes
roncodes deleted the fix/qpay-auth-token-expiry branch September 4, 2026 10:53
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant