Skip to content

feat(appcheck): Verify one-time tokens for replay protection - #976

Merged
yvonnep165 merged 15 commits into
mainfrom
yp-verify-one-time-token
Sep 16, 2026
Merged

yvonnep165 merged 15 commits into
mainfrom
yp-verify-one-time-token

Conversation

@yvonnep165

Copy link
Copy Markdown
Contributor

This PR adds support for App Check one-time token verification for replay protection by adding an optional consume parameter to app_check.verify_token(). The returned claims dictionary will contain an already_consumed boolean key indicating whether the token was previously consumed.

@yvonnep165 yvonnep165 self-assigned this Aug 17, 2026
@yvonnep165 yvonnep165 added release-note release:stage Stage a release candidate labels Aug 17, 2026

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request adds support for stateful token verification in Firebase App Check by introducing a consume parameter to the verify_token function. When enabled, the service calls the App Check backend to mark the token as consumed, providing replay protection, and returns an already_consumed flag. Corresponding unit tests have also been added. The review feedback suggests improving error handling by using _utils.handle_platform_error_from_requests instead of _utils.handle_requests_error to propagate detailed GCP error messages to developers.

Comment thread firebase_admin/app_check.py Outdated

@jonathanedey jonathanedey left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM with one comment, Thanks!

Comment thread firebase_admin/app_check.py

@weixifan weixifan left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for this PR!

@lahirumaramba lahirumaramba left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks! Looks great!
I added a few comments, let me know what you think.

Comment thread firebase_admin/app_check.py Outdated

verified_claims['app_id'] = verified_claims.get('sub')

if not isinstance(consume, bool):

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's move this to the beginning of the function (along side of check_string) that way we fail fast for invalid inputs before obtaining the key sets. If you think this check will be reused, take a look at the check_boolean helper in firebase_admin/_messaging_encoder.py

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this also missing unit tests

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done! Added check_boolean to _Validators and moved the validation to the top before doing any JWKS/crypto operations. Also added unit tests for non-boolean consume arguments.

Comment thread firebase_admin/app_check.py Outdated
raise _utils.handle_platform_error_from_requests(error)

already_consumed = False
if isinstance(body, dict):

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What if body is not a dictionary, do we fail silently and return the token as not already consumed?
This could be an issue if the BE response is malformed for some reason and we silently falls back to already_consumed = False. WDYT about throwing an exception here?

if not isinstance(body, dict):
    raise exceptions.UnknownError(
        'Unexpected response from App Check service. '
        f'Expected a JSON object, but got {type(body).__name__}.')

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Great catch! I've updated the logic to raise exceptions.UnknownError if body is not a dict, and added unit test coverage for malformed/non-dict responses.

Comment thread firebase_admin/app_check.py Outdated
if not isinstance(consume, bool):
raise ValueError('consume must be a boolean.')
if consume:
url = self._VERIFY_URL_FORMAT.format(project_id=self._project_id)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: moving the replay verification to a separate helper method will make the main verify token function less cluttered.

WDYT about something like:

def _verify_replay_protection(self, token: str) -> bool:
    url = self._VERIFY_URL_FORMAT.format(project_id=self._project_id)
    try:
        body = self._http_client.body('post', url, json={'app_check_token': token})
    except requests.exceptions.RequestException as error:
        raise _utils.handle_platform_error_from_requests(error)
    except ValueError as error:
        raise exceptions.UnknownError(f'Unexpected response from App Check service: {error}')

    if not isinstance(body, dict):
        raise exceptions.UnknownError(
            'Unexpected response from App Check service. '
            f'Expected a JSON object, but got {type(body).__name__}.')
    return bool(body.get('alreadyConsumed', False))

Comment thread firebase_admin/app_check.py Outdated
if consume:
url = self._VERIFY_URL_FORMAT.format(project_id=self._project_id)
try:
body = self._http_client.body('post', url, json={'app_check_token': token})

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Add a test to verify when consume=False (the default), no HTTP call is made to _http_client.body, and already_consumed is not present in the returned dictionary.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added.

Comment thread tests/test_app_check.py Outdated
req_exc = requests.exceptions.RequestException("Backend error")
mocker.patch.object(app_check_service._http_client, "body", side_effect=req_exc)

with pytest.raises(exceptions.FirebaseError):

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: can we test for more specific error types instead of checking the more broader FirebaseError?

Comment thread tests/test_app_check.py Outdated
def test_verify_token_with_consume_true_not_consumed(self, mocker):
mocker.patch("jwt.decode", return_value=JWT_PAYLOAD_SAMPLE)
mocker.patch("jwt.PyJWKClient.get_signing_key_from_jwt", return_value=PyJWK(signing_key))
mocker.patch("jwt.get_unverified_header", return_value=JWT_PAYLOAD_SAMPLE.get("headers"))

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can these mocker.patch calls be grouped into a pytest fixture and be reused in here, ... already_consumed , and ...backend_error?

Comment thread firebase_admin/app_check.py Outdated
Raises:
ValueError: If the app's ``project_id`` is invalid or unspecified,
or if the token's headers or payload are invalid.
or if the token's headers or payload are invalid.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ValueError: If ``consume`` is not a boolean, or if the app's ``project_id``...

@yvonnep165
yvonnep165 requested a review from a team September 16, 2026 20:00

@lahirumaramba lahirumaramba left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM! Thank you! :)

@yvonnep165
yvonnep165 merged commit 2ef3414 into main Sep 16, 2026
24 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

release:stage Stage a release candidate release-note

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants