Skip to content

Commit 91cff5a

Browse files
committed
Add App Check replay protection with transitional contract
1 parent 3a3a016 commit 91cff5a

File tree

12 files changed

+194
-12
lines changed

12 files changed

+194
-12
lines changed

CHANGELOG.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,19 @@ If it saves you or your team time, please consider [sponsoring its development](
77
The namespace remains `Kreait\Firebase` and the package name remains `kreait/firebase-php`.
88
Please update your remote URL if you have forked or cloned the repository.
99

10+
## Unreleased
11+
12+
### App Check
13+
14+
* Added replay-protection verification for App Check tokens via `verifyTokenWithReplayProtection()`.
15+
The response now includes `alreadyConsumed` when replay protection is used.
16+
* Added transitional contract `Kreait\Firebase\Contract\AppCheckWithReplayProtection`.
17+
This was introduced to preserve backwards compatibility by avoiding a signature change to
18+
`Kreait\Firebase\Contract\AppCheck::verifyToken()` in the current major release.
19+
* Added dedicated exception `Kreait\Firebase\Exception\AppCheck\FailedToVerifyAppCheckReplayProtection`
20+
for replay-protection verification failures. It extends
21+
`Kreait\Firebase\Exception\AppCheck\FailedToVerifyAppCheckToken` for backwards compatibility.
22+
1023
## 8.1.0 - 2026-01-23
1124

1225
* Added support for `firebase/php-jwt:^7.0.2`

NEXT_MAJOR_TODO.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
# Next Major TODO
2+
3+
## App Check Replay Protection
4+
5+
- Context: `Kreait\Firebase\Contract\AppCheckWithReplayProtection` was introduced as a transitional contract
6+
to preserve backwards compatibility in the current major release.
7+
- Fold replay protection into `Kreait\Firebase\Contract\AppCheck::verifyToken()` (e.g. argument/options),
8+
and remove the transitional `Kreait\Firebase\Contract\AppCheckWithReplayProtection` contract.
9+
- Update docs and integration tests accordingly after the API consolidation.

docs/app-check.rst

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,11 +35,38 @@ See https://firebase.google.com/docs/app-check/custom-resource-backend for more
3535
$appCheckTokenString = '...';
3636
3737
try {
38-
$appCheck->verifyToken($appCheckTokenString);
38+
$verification = $appCheck->verifyToken($appCheckTokenString);
3939
} catch (FailedToVerifyAppCheckToken $e) {
4040
// The token is invalid
4141
}
4242
43+
To enable replay protection for a security-critical endpoint, use the replay-protection contract method.
44+
This performs an additional call to the App Check API and reports whether the token has already been consumed.
45+
46+
.. note::
47+
Replay protection is currently exposed through ``Kreait\Firebase\Contract\AppCheckWithReplayProtection`` as
48+
a transitional API to avoid a backwards-incompatible signature change in ``AppCheck::verifyToken()`` and
49+
preserve backwards compatibility in the current major version.
50+
In the next major release, this should be folded into ``AppCheck::verifyToken()``.
51+
52+
.. code-block:: php
53+
use Kreait\Firebase\Contract\AppCheck;
54+
use Kreait\Firebase\Contract\AppCheckWithReplayProtection;
55+
use Kreait\Firebase\Exception\AppCheck\FailedToVerifyAppCheckReplayProtection;
56+
57+
/** @var AppCheck&AppCheckWithReplayProtection $appCheck */
58+
$verification = null;
59+
60+
try {
61+
$verification = $appCheck->verifyTokenWithReplayProtection($appCheckTokenString);
62+
} catch (FailedToVerifyAppCheckReplayProtection $e) {
63+
// The token could not be consumed for replay protection.
64+
}
65+
66+
if ($verification?->alreadyConsumed === true) {
67+
// Reject the request as a replay attempt.
68+
}
69+
4370
.. _create-a-custom-provider:
4471

4572
************************

src/AppCheck.php

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,14 +10,15 @@
1010
use Kreait\Firebase\AppCheck\AppCheckTokenOptions;
1111
use Kreait\Firebase\AppCheck\AppCheckTokenVerifier;
1212
use Kreait\Firebase\AppCheck\VerifyAppCheckTokenResponse;
13+
use Kreait\Firebase\Contract\AppCheckWithReplayProtection;
1314
use SensitiveParameter;
1415

1516
use function is_array;
1617

1718
/**
1819
* @internal
1920
*/
20-
final readonly class AppCheck implements Contract\AppCheck
21+
final readonly class AppCheck implements Contract\AppCheck, AppCheckWithReplayProtection
2122
{
2223
public function __construct(
2324
private ApiClient $client,
@@ -40,8 +41,11 @@ public function createToken(string $appId, AppCheckTokenOptions|array|null $opti
4041

4142
public function verifyToken(#[SensitiveParameter] string $appCheckToken): VerifyAppCheckTokenResponse
4243
{
43-
$decodedToken = $this->tokenVerifier->verifyToken($appCheckToken);
44+
return $this->tokenVerifier->verifyToken($appCheckToken);
45+
}
4446

45-
return new VerifyAppCheckTokenResponse($decodedToken->app_id, $decodedToken);
47+
public function verifyTokenWithReplayProtection(#[SensitiveParameter] string $appCheckToken): VerifyAppCheckTokenResponse
48+
{
49+
return $this->tokenVerifier->verifyToken($appCheckToken, true);
4650
}
4751
}

src/AppCheck/ApiClient.php

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,13 @@
66

77
use Beste\Json;
88
use GuzzleHttp\ClientInterface;
9+
use Kreait\Firebase\Exception\AppCheck\AppCheckError;
910
use Kreait\Firebase\Exception\AppCheckApiExceptionConverter;
1011
use Kreait\Firebase\Exception\AppCheckException;
1112
use Psr\Http\Message\ResponseInterface;
13+
use SensitiveParameter;
1214
use Throwable;
15+
use function is_bool;
1316

1417
/**
1518
* @internal
@@ -46,6 +49,37 @@ public function exchangeCustomToken(string $appId, string $customToken): array
4649
return $decoded;
4750
}
4851

52+
/**
53+
* @param non-empty-string $projectId
54+
*
55+
* @throws AppCheckException
56+
*/
57+
public function verifyReplayProtection(#[SensitiveParameter] string $token, string $projectId): bool
58+
{
59+
$response = $this->post(
60+
'https://firebaseappcheck.googleapis.com/v1beta/projects/'.$projectId.':verifyAppCheckToken',
61+
[
62+
'headers' => [
63+
'Content-Type' => 'application/json; UTF-8',
64+
],
65+
'body' => Json::encode([
66+
'app_check_token' => $token,
67+
]),
68+
],
69+
);
70+
71+
/** @var array{alreadyConsumed?: mixed} $decoded */
72+
$decoded = Json::decode((string) $response->getBody(), true);
73+
74+
$alreadyConsumed = $decoded['alreadyConsumed'] ?? false;
75+
76+
if (!is_bool($alreadyConsumed)) {
77+
throw new AppCheckError('The App Check API returned an invalid "alreadyConsumed" value.');
78+
}
79+
80+
return $alreadyConsumed;
81+
}
82+
4983
/**
5084
* @param non-empty-string $path
5185
* @param array<string, mixed>|null $options

src/AppCheck/AppCheckTokenVerifier.php

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66

77
use Firebase\JWT\CachedKeySet;
88
use Firebase\JWT\JWT;
9+
use Kreait\Firebase\Exception\AppCheck\FailedToVerifyAppCheckReplayProtection;
910
use Kreait\Firebase\Exception\AppCheck\FailedToVerifyAppCheckToken;
1011
use Kreait\Firebase\Exception\AppCheck\InvalidAppCheckToken;
1112
use LogicException;
@@ -30,24 +31,40 @@
3031
public function __construct(
3132
private string $projectId,
3233
private CachedKeySet $keySet,
34+
private ApiClient $apiClient,
3335
) {
3436
}
3537

3638
/**
3739
* Verifies the format and signature of a Firebase App Check token.
3840
*
3941
* @param string $token the Firebase Auth JWT token to verify
42+
* @param bool $consume whether the token should be consumed for replay protection
4043
*
4144
* @throws FailedToVerifyAppCheckToken if the token could not be verified
45+
* @throws FailedToVerifyAppCheckReplayProtection if replay protection could not be verified
4246
* @throws InvalidAppCheckToken if the token is invalid
4347
*/
44-
public function verifyToken(#[SensitiveParameter] string $token): DecodedAppCheckToken
48+
public function verifyToken(#[SensitiveParameter] string $token, bool $consume = false): VerifyAppCheckTokenResponse
4549
{
4650
$decodedToken = $this->decodeJwt($token);
4751

4852
$this->verifyContent($decodedToken);
4953

50-
return $decodedToken;
54+
$alreadyConsumed = null;
55+
56+
if ($consume) {
57+
try {
58+
$alreadyConsumed = $this->apiClient->verifyReplayProtection($token, $this->projectId);
59+
} catch (Throwable $e) {
60+
throw new FailedToVerifyAppCheckReplayProtection(
61+
message: 'Unable to verify App Check token replay protection: '.$e->getMessage(),
62+
previous: $e,
63+
);
64+
}
65+
}
66+
67+
return new VerifyAppCheckTokenResponse($decodedToken->app_id, $decodedToken, $alreadyConsumed);
5168
}
5269

5370
/**

src/AppCheck/VerifyAppCheckTokenResponse.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
* @phpstan-type VerifyAppCheckTokenResponseShape array{
1111
* appId: non-empty-string,
1212
* token: DecodedAppCheckTokenShape,
13+
* alreadyConsumed?: bool,
1314
* }
1415
*/
1516
final readonly class VerifyAppCheckTokenResponse
@@ -20,6 +21,7 @@
2021
public function __construct(
2122
public string $appId,
2223
public DecodedAppCheckToken $token,
24+
public ?bool $alreadyConsumed = null,
2325
) {
2426
}
2527
}
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Kreait\Firebase\Contract;
6+
7+
use Kreait\Firebase\AppCheck\VerifyAppCheckTokenResponse;
8+
use Kreait\Firebase\Exception;
9+
use Kreait\Firebase\Exception\AppCheck\FailedToVerifyAppCheckReplayProtection;
10+
use Kreait\Firebase\Exception\AppCheck\FailedToVerifyAppCheckToken;
11+
use Kreait\Firebase\Exception\AppCheck\InvalidAppCheckToken;
12+
use SensitiveParameter;
13+
14+
/**
15+
* Transitional contract for replay protection support.
16+
*
17+
* This interface exists to provide replay protection without changing the
18+
* existing AppCheck::verifyToken() signature in a backwards-incompatible way.
19+
* In a future major release, replay protection should be moved into
20+
* AppCheck::verifyToken() as an additional argument/options parameter.
21+
*/
22+
interface AppCheckWithReplayProtection
23+
{
24+
/**
25+
* Verifies an App Check token and consumes it for replay protection.
26+
*
27+
* @param non-empty-string $appCheckToken
28+
*
29+
* @throws InvalidAppCheckToken
30+
* @throws FailedToVerifyAppCheckToken
31+
* @throws FailedToVerifyAppCheckReplayProtection
32+
* @throws Exception\FirebaseException
33+
*/
34+
public function verifyTokenWithReplayProtection(#[SensitiveParameter] string $appCheckToken): VerifyAppCheckTokenResponse;
35+
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Kreait\Firebase\Exception\AppCheck;
6+
7+
final class FailedToVerifyAppCheckReplayProtection extends FailedToVerifyAppCheckToken
8+
{
9+
}

src/Exception/AppCheck/FailedToVerifyAppCheckToken.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,6 @@
77
use Kreait\Firebase\Exception\AppCheckException;
88
use Kreait\Firebase\Exception\RuntimeException;
99

10-
final class FailedToVerifyAppCheckToken extends RuntimeException implements AppCheckException
10+
class FailedToVerifyAppCheckToken extends RuntimeException implements AppCheckException
1111
{
1212
}

0 commit comments

Comments
 (0)