allow vendord
All checks were successful
Build, Push and Deploy / build-and-push (push) Successful in 3m3s
Gitea Actions Demo / Explore-Gitea-Actions (push) Successful in 16s
Build, Push and Deploy / deploy-staging (push) Successful in 32s
Build, Push and Deploy / deploy-production (push) Has been skipped
Build, Push and Deploy / cleanup (push) Successful in 1s

This commit is contained in:
2026-03-30 14:54:57 +07:00
parent 66aed7c4e8
commit b5e3a778ce
21316 changed files with 2777892 additions and 13 deletions

View File

@@ -0,0 +1,30 @@
# CHANGELOG
## [Unreleased]
## 5.3.0 - 2025-09-12
* Added support for PHP 8.5
## 5.2.1 - 2024-12-20
* Fixed deprecated implicit nullable parameter
## 5.2.0 - 2024-08-17
* Added support for PHP 8.4
([#61](https://github.com/kreait/firebase-tokens-php/pull/61))
## 5.1.0 - 2024-05-10
* Restored support for PHP 8.1
* Fixed missing signature check when in non-emulated environments
([#56](https://github.com/kreait/firebase-tokens-php/pull/56))
## 5.0.1 - 2023-11-29
* Fixed ID Token verification when run in emulated environments.
## 5.0.0 - 2023-11-25
* Added support for PHP 8.3, removed support for PHP 8.1

21
vendor/kreait/firebase-tokens/LICENSE vendored Normal file
View File

@@ -0,0 +1,21 @@
The MIT License (MIT)
Copyright (c) Jérôme Gamez, https://github.com/jeromegamez <jerome@gamez.name>
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

257
vendor/kreait/firebase-tokens/README.md vendored Normal file
View File

@@ -0,0 +1,257 @@
# Firebase Tokens
A library to work with [Google Firebase](https://firebase.google.com) tokens. You can use it to
[create custom tokens](https://firebase.google.com/docs/auth/admin/create-custom-tokens) and
[verify ID Tokens](https://firebase.google.com/docs/auth/admin/verify-id-tokens).
Achieve more with the [Firebase Admin SDK](https://github.com/kreait/firebase-php) for PHP (which uses this library).
[![Current version](https://img.shields.io/packagist/v/kreait/firebase-tokens.svg)](https://packagist.org/packages/kreait/firebase-tokens)
[![Supported PHP version](https://img.shields.io/packagist/php-v/kreait/firebase-tokens.svg)]()
[![Monthly Downloads](https://img.shields.io/packagist/dm/kreait/firebase-tokens.svg)](https://packagist.org/packages/kreait/firebase-tokens/stats)
[![Total Downloads](https://img.shields.io/packagist/dt/kreait/firebase-tokens.svg)](https://packagist.org/packages/kreait/firebase-tokens/stats)
[![Tests](https://github.com/kreait/firebase-tokens-php/workflows/Tests/badge.svg)](https://github.com/kreait/firebase-tokens-php/actions)
[![Sponsor](https://img.shields.io/static/v1?logo=GitHub&label=Sponsor&message=%E2%9D%A4&color=ff69b4)](https://github.com/sponsors/jeromegamez)
---
## The future of the Firebase Admin PHP SDK
Please read about the future of the Firebase Admin PHP SDK on the
[SDK's GitHub Repository](https://github.com/kreait/firebase-php).
---
- [Installation](#installation)
- [Simple Usage](#simple-usage)
- [Create a custom token](#create-a-custom-token)
- [Verify an ID token](#verify-an-id-token)
- [Verify a Session Cookie](#verify-a-session-cookie)
- [Tokens](#tokens)
- [Tenant Awareness](#tenant-awareness)
- [Advanced Usage](#advanced-usage)
- [Cache results from the Google Secure Token Store](#cache-results-from-the-google-secure-token-store)
- [Supported Versions](#supported-versions)
## Installation
```bash
composer require kreait/firebase-tokens
```
## Simple usage
### Create a custom token
More information on what a custom token is and how it can be used can be found in
[Google's official documentation](https://firebase.google.com/docs/auth/admin/create-custom-tokens).
```php
<?php
use Kreait\Firebase\JWT\CustomTokenGenerator;
$clientEmail = '...';
$privateKey = '...';
$generator = CustomTokenGenerator::withClientEmailAndPrivateKey($clientEmail, $privateKey);
$token = $generator->createCustomToken('uid', ['first_claim' => 'first_value' /* ... */]);
echo $token;
// Output: eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9.e...
```
### Verify an ID token
The ID token verification methods included in the Firebase Admin SDKs are meant to verify
ID tokens that come from the client SDKs, not the custom tokens that you create with the Admin SDKs.
See [Auth tokens](https://firebase.google.com/docs/auth/users/#auth_tokens) for more information.
```php
<?php
use Kreait\Firebase\JWT\Error\IdTokenVerificationFailed;
use Kreait\Firebase\JWT\IdTokenVerifier;
$projectId = '...';
$idToken = 'eyJhb...'; // An ID token given to your backend by a Client application
$verifier = IdTokenVerifier::createWithProjectId($projectId);
try {
$token = $verifier->verifyIdToken($idToken);
} catch (IdTokenVerificationFailed $e) {
echo $e->getMessage();
// Example Output:
// The value 'eyJhb...' is not a verified ID token:
// - The token is expired.
exit;
}
try {
$token = $verifier->verifyIdTokenWithLeeway($idToken, $leewayInSeconds = 10000000);
} catch (IdTokenVerificationFailed $e) {
print $e->getMessage();
exit;
}
```
### Verify a Session Cookie
Session cookie verification is similar to ID Token verification.
See [Manage Session Cookies](https://firebase.google.com/docs/auth/admin/manage-cookies) for more information.
```php
<?php
use Kreait\Firebase\JWT\Error\SessionCookieVerificationFailed;
use Kreait\Firebase\JWT\SessionCookieVerifier;
$projectId = '...';
$sessionCookie = 'eyJhb...'; // A session cookie given to your backend by a Client application
$verifier = SessionCookieVerifier::createWithProjectId($projectId);
try {
$token = $verifier->verifySessionCookie($sessionCookie);
} catch (SessionCookieVerificationFailed $e) {
echo $e->getMessage();
// Example Output:
// The value 'eyJhb...' is not a verified ID token:
// - The token is expired.
exit;
}
try {
$token = $verifier->verifySessionCookieWithLeeway($sessionCookie, $leewayInSeconds = 10000000);
} catch (SessionCookieVerificationFailed $e) {
print $e->getMessage();
exit;
}
```
### Tokens
Tokens returned from the Generator and Verifier are instances of `\Kreait\Firebase\JWT\Contract\Token` and
represent a [JWT](https://jwt.io/). The displayed outputs are examples and vary depending on
the information associated with the given user in your project's auth database.
According to the JWT specification, you can expect the following payload fields to be always
available: `iss`, `aud`, `auth_time`, `sub`, `iat`, `exp`. Other fields depend on the
authentication method of the given account and the information stored in your project's
Auth database.
```php
$token = $verifier->verifyIdToken('eyJhb...'); // An ID token given to your backend by a Client application
echo json_encode($token->headers(), JSON_PRETTY_PRINT);
// {
// "alg": "RS256",
// "kid": "e5a91d9f39fa4de254a1e89df00f05b7e248b985",
// "typ": "JWT"
// }
echo json_encode($token->payload(), JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
// {
// "name": "Jane Doe",
// "picture": "https://domain.tld/picture.jpg",
// "iss": "https://securetoken.google.com/your-project-id",
// "aud": "your-project-id",
// "auth_time": 1580063945,
// "user_id": "W0IturDwy4TYTmX6ilkd2ZbAXRp2",
// "sub": "W0IturDwy4TYTmX6ilkd2ZbAXRp2",
// "iat": 1580063945,
// "exp": 1580067545,
// "email": "jane@doe.tld",
// "email_verified": true,
// "phone_number": "+1234567890",
// "firebase": {
// "identities": {
// "phone": [
// "+1234567890"
// ],
// "email": [
// "jane@doe.tld"
// ]
// },
// "sign_in_provider": "custom"
// }
// }
echo $token->toString();
// eyJhb...
$tokenString = (string) $token; // string
// eyJhb...
```
### Tenant Awareness
You can create custom tokens that are scoped to a given tenant:
```php
<?php
use Kreait\Firebase\JWT\CustomTokenGenerator;
$generator = CustomTokenGenerator::withClientEmailAndPrivateKey('...', '...');
$tenantAwareGenerator = $generator->withTenantId('my-tenant-id');
```
Similarly, you can verify that ID tokens were issued in the scope of a given tenant:
```php
<?php
use Kreait\Firebase\JWT\IdTokenVerifier;
$verifier = IdTokenVerifier::createWithProjectId('my-project-id');
$tenantAwareVerifier = $verifier->withExpectedTenantId('my-tenant-id');
```
Session cookies currently don't support tenants.
## Advanced usage
### Cache results from the Google Secure Token Store
In order to verify ID tokens, the verifier makes a call to fetch Firebase's currently available public
keys. The keys are cached in memory by default.
If you want to cache the public keys more effectively, you can initialize the verifier with an
implementation of [psr/simple-cache](https://packagist.org/providers/psr/simple-cache-implementation)
or [psr/cache](https://packagist.org/providers/psr/cache-implementation) to reduce the amount
of HTTP requests to Google's servers.
Here's an example using the [Symfony Cache Component](https://symfony.com/doc/current/components/cache.html):
```php
use Kreait\Firebase\JWT\IdTokenVerifier;
use Symfony\Component\Cache\Adapter\FilesystemAdapter;
$cache = new FilesystemAdapter();
$verifier = IdTokenVerifier::createWithProjectIdAndCache($projectId, $cache);
```
## Supported Versions
**Only the latest version is actively supported.**
Earlier versions will receive security fixes as long as their **lowest** PHP requirement receives security fixes. For
example, when a version supports PHP 7.4 and PHP 8.0, security support will end when security support for PHP 7.4 ends.
| Version | Initial Release | Supported PHP Versions | Status |
|---------|-----------------|------------------------------------------|-------------|
| `5.x` | 25 Nov 2023 | `~8.1.0, ~8.2.0, ~8.3.0, ~8.4.0, ~8.5.0` | Active |
| `4.x` | 26 Nov 2022 | `~8.1.0, ~8.2.0, ~8.3.0` | End of life |
| `3.x` | 25 Apr 2022 | `^7.4, ^8.0` | End of life |
| `2.x` | 03 Jan 2022 | `^7.4, ^8.0` | End of life |
| `1.x` | 06 Feb 2017 | `>=5.5` | End of life |
## License
The MIT License (MIT). Please see [License File](LICENSE) for more information.

View File

@@ -0,0 +1,5 @@
## Security contact information
To report a security vulnerability, please use the
[Tidelift security contact](https://tidelift.com/security).
Tidelift will coordinate the fix and disclosure.

View File

@@ -0,0 +1,87 @@
{
"name": "kreait/firebase-tokens",
"description": "A library to work with Firebase tokens",
"type": "library",
"keywords": ["firebase", "google", "token", "authentication", "auth"],
"homepage": "https://github.com/kreait/firebase-token-php",
"license": "MIT",
"authors": [
{
"name": "Jérôme Gamez",
"homepage": "https://github.com/jeromegamez"
}
],
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/jeromegamez"
}
],
"require": {
"php": "~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0",
"ext-json": "*",
"ext-openssl": "*",
"beste/clock": "^3.0",
"fig/http-message-util": "^1.1.5",
"guzzlehttp/guzzle": "^7.8",
"lcobucci/jwt": "^5.2",
"psr/cache": "^1.0|^2.0|^3.0"
},
"suggest": {
"psr/cache-implementation": "to cache fetched remote public keys"
},
"require-dev": {
"friendsofphp/php-cs-fixer": "^3.62.0",
"phpstan/extension-installer": "^1.4.1",
"phpstan/phpstan": "^2.1",
"phpstan/phpstan-phpunit": "^2.0",
"phpunit/phpunit": "^10.5.30",
"rector/rector": "^2.1",
"symfony/cache": "^6.4.3 || ^7.1.3",
"symfony/var-dumper": "^6.4.3 || ^7.1.3"
},
"autoload": {
"psr-4": {
"Kreait\\Firebase\\JWT\\":"src/JWT"
}
},
"autoload-dev": {
"psr-4": {
"Kreait\\Firebase\\JWT\\Tests\\": "tests/JWT"
}
},
"config": {
"sort-packages": true,
"allow-plugins": {
"phpstan/extension-installer": true
}
},
"scripts": {
"analyse": [
"XDEBUG_MODE=off vendor/bin/phpstan"
],
"analyze": "@analyse",
"cs": [
"PHP_CS_FIXER_IGNORE_ENV=true vendor/bin/php-cs-fixer fix --diff --verbose"
],
"rector": [
"vendor/bin/rector --dry-run"
],
"rector-fix": [
"vendor/bin/rector",
"@cs"
],
"test": [
"@analyze",
"@test-units",
"@test-emulator"
],
"test-emulator": [
"FIREBASE_AUTH_EMULATOR_HOST=localhost:9099",
"firebase emulators:exec --only auth --project demo-project 'vendor/bin/phpunit --group=emulator --testdox'"
],
"test-units": [
"vendor/bin/phpunit --exclude-group=emulator --testdox"
]
}
}

View File

@@ -0,0 +1,123 @@
<?php
declare(strict_types=1);
namespace Kreait\Firebase\JWT\Action;
use DateInterval;
use InvalidArgumentException;
use Kreait\Firebase\JWT\Value\Duration;
final class CreateCustomToken
{
public const MINIMUM_TTL = 'PT1S';
public const MAXIMUM_TTL = 'PT1H';
public const DEFAULT_TTL = self::MAXIMUM_TTL;
private ?string $tenantId = null;
/** @var array<string, mixed> */
private array $customClaims = [];
private Duration $ttl;
private function __construct(private string $uid)
{
$this->ttl = Duration::fromDateIntervalSpec(self::DEFAULT_TTL);
}
public static function forUid(string $uid): self
{
return new self($uid);
}
public function withTenantId(string $tenantId): self
{
$action = clone $this;
$action->tenantId = $tenantId;
return $action;
}
public function withChangedUid(string $uid): self
{
$action = clone $this;
$action->uid = $uid;
return $action;
}
public function withCustomClaim(string $name, mixed $value): self
{
$action = clone $this;
$action->customClaims[$name] = $value;
return $action;
}
/**
* @param array<string, mixed> $claims
*/
public function withCustomClaims(array $claims): self
{
$action = clone $this;
$action->customClaims = $claims;
return $action;
}
/**
* @param array<string, mixed> $claims
*/
public function withAddedCustomClaims(array $claims): self
{
$action = clone $this;
$action->customClaims = [...$action->customClaims, ...$claims];
return $action;
}
public function withTimeToLive(Duration|DateInterval|string|int $ttl): self
{
$ttl = Duration::make($ttl);
$minTtl = Duration::fromDateIntervalSpec(self::MINIMUM_TTL);
$maxTtl = Duration::fromDateIntervalSpec(self::MAXIMUM_TTL);
if ($ttl->isSmallerThan($minTtl) || $ttl->isLargerThan($maxTtl)) {
$message = 'The expiration time of a custom token must be between %s and %s, but got %s';
throw new InvalidArgumentException(sprintf($message, $minTtl, $maxTtl, $ttl));
}
$action = clone $this;
$action->ttl = $ttl;
return $action;
}
public function uid(): string
{
return $this->uid;
}
public function tenantId(): ?string
{
return $this->tenantId;
}
/**
* @return array<string, mixed>
*/
public function customClaims(): array
{
return $this->customClaims;
}
public function timeToLive(): Duration
{
return $this->ttl;
}
}

View File

@@ -0,0 +1,17 @@
<?php
declare(strict_types=1);
namespace Kreait\Firebase\JWT\Action\CreateCustomToken;
use Kreait\Firebase\JWT\Action\CreateCustomToken;
use Kreait\Firebase\JWT\Contract\Token;
use Kreait\Firebase\JWT\Error\CustomTokenCreationFailed;
interface Handler
{
/**
* @throws CustomTokenCreationFailed
*/
public function handle(CreateCustomToken $action): Token;
}

View File

@@ -0,0 +1,86 @@
<?php
declare(strict_types=1);
namespace Kreait\Firebase\JWT\Action\CreateCustomToken;
use DateTimeInterface;
use Kreait\Firebase\JWT\Action\CreateCustomToken;
use Kreait\Firebase\JWT\Contract\Token;
use Kreait\Firebase\JWT\Error\CustomTokenCreationFailed;
use Kreait\Firebase\JWT\SecureToken;
use Lcobucci\JWT\Configuration;
use Lcobucci\JWT\Signer\Key\InMemory;
use Lcobucci\JWT\Signer\Rsa\Sha256;
use Psr\Clock\ClockInterface;
use Throwable;
/**
* @internal
*/
final class WithLcobucciJWT implements Handler
{
private readonly Configuration $config;
/**
* @param non-empty-string $clientEmail
* @param non-empty-string $privateKey
*/
public function __construct(
private readonly string $clientEmail,
string $privateKey,
private readonly ClockInterface $clock,
) {
$this->config = Configuration::forSymmetricSigner(
new Sha256(),
InMemory::plainText($privateKey),
);
}
public function handle(CreateCustomToken $action): Token
{
$now = $this->clock->now();
$builder = $this->config->builder()
->issuedAt($now)
->issuedBy($this->clientEmail)
->expiresAt($now->add($action->timeToLive()->value()))
->relatedTo($this->clientEmail)
->permittedFor('https://identitytoolkit.googleapis.com/google.identity.identitytoolkit.v1.IdentityToolkit')
->withClaim('uid', $action->uid());
if ($tenantId = $action->tenantId()) {
$builder = $builder->withClaim('tenant_id', $tenantId);
}
if (!empty($customClaims = $action->customClaims())) {
$builder = $builder->withClaim('claims', $customClaims);
}
try {
$token = $builder->getToken($this->config->signer(), $this->config->signingKey());
} catch (Throwable $e) {
throw CustomTokenCreationFailed::because($e->getMessage(), $e->getCode(), $e);
}
$claims = $token->claims()->all();
foreach ($claims as &$claim) {
if ($claim instanceof DateTimeInterface) {
$claim = $claim->getTimestamp();
}
}
unset($claim);
$headers = $token->headers()->all();
foreach ($headers as &$header) {
if ($header instanceof DateTimeInterface) {
$header = $header->getTimestamp();
}
}
unset($header);
return SecureToken::withValues($token->toString(), $headers, $claims);
}
}

View File

@@ -0,0 +1,74 @@
<?php
declare(strict_types=1);
namespace Kreait\Firebase\JWT\Action;
use DateInterval;
use Kreait\Firebase\JWT\Value\Duration;
final class FetchGooglePublicKeys
{
public const DEFAULT_URLS = [
'https://www.googleapis.com/robot/v1/metadata/x509/securetoken@system.gserviceaccount.com',
'https://www.googleapis.com/oauth2/v1/certs',
'https://www.googleapis.com/identitytoolkit/v3/relyingparty/publicKeys',
];
public const DEFAULT_FALLBACK_CACHE_DURATION = 'PT1H';
/** @var array<int, non-empty-string> */
private readonly array $urls;
/**
* @param array<array-key, non-empty-string> $urls
*/
private function __construct(array $urls, private Duration $fallbackCacheDuration)
{
$this->urls = array_values($urls);
}
public static function fromGoogle(): self
{
return new self(self::DEFAULT_URLS, Duration::fromDateIntervalSpec(self::DEFAULT_FALLBACK_CACHE_DURATION));
}
/**
* Use this method only if Google has changed the default URL and the library hasn't been updated yet.
*
* @param non-empty-string $url
*/
public static function fromUrl(string $url): self
{
return new self([$url], Duration::fromDateIntervalSpec(self::DEFAULT_FALLBACK_CACHE_DURATION));
}
/**
* A response from the Google APIs should have a cache control header that determines when the keys expire.
* If it doesn't have one, fall back to this value.
*
* @param Duration|DateInterval|non-empty-string|int $duration
*/
public function ifKeysDoNotExpireCacheFor(Duration|DateInterval|string|int $duration): self
{
$duration = Duration::make($duration);
$action = clone $this;
$action->fallbackCacheDuration = $duration;
return $action;
}
/**
* @return array<int, string>
*/
public function urls(): array
{
return $this->urls;
}
public function getFallbackCacheDuration(): Duration
{
return $this->fallbackCacheDuration;
}
}

View File

@@ -0,0 +1,17 @@
<?php
declare(strict_types=1);
namespace Kreait\Firebase\JWT\Action\FetchGooglePublicKeys;
use Kreait\Firebase\JWT\Action\FetchGooglePublicKeys;
use Kreait\Firebase\JWT\Contract\Keys;
use Kreait\Firebase\JWT\Error\FetchingGooglePublicKeysFailed;
interface Handler
{
/**
* @throws FetchingGooglePublicKeysFailed
*/
public function handle(FetchGooglePublicKeys $action): Keys;
}

View File

@@ -0,0 +1,101 @@
<?php
declare(strict_types=1);
namespace Kreait\Firebase\JWT\Action\FetchGooglePublicKeys;
use Fig\Http\Message\RequestMethodInterface as RequestMethod;
use GuzzleHttp\ClientInterface;
use GuzzleHttp\Exception\GuzzleException;
use JsonException;
use Kreait\Firebase\JWT\Action\FetchGooglePublicKeys;
use Kreait\Firebase\JWT\Contract\Keys;
use Kreait\Firebase\JWT\Error\FetchingGooglePublicKeysFailed;
use Kreait\Firebase\JWT\Keys\ExpiringKeys;
use Psr\Clock\ClockInterface;
use const JSON_THROW_ON_ERROR;
/**
* @internal
*/
final class WithGuzzle implements Handler
{
public function __construct(
private readonly ClientInterface $client,
private readonly ClockInterface $clock,
) {
}
public function handle(FetchGooglePublicKeys $action): Keys
{
$keys = [];
$ttls = [];
foreach ($action->urls() as $url) {
$result = $this->fetchKeysFromUrl($url);
$keys[] = $result['keys'];
$ttls[] = $result['ttl'];
}
$keys = array_merge(...$keys);
$ttl = $ttls !== [] ? min($ttls) : 0;
$now = $this->clock->now();
$expiresAt = $ttl > 0
? $now->setTimestamp($now->getTimestamp() + $ttl)
: $now->add($action->getFallbackCacheDuration()->value());
return ExpiringKeys::withValuesAndExpirationTime($keys, $expiresAt);
}
/**
* @return array{
* keys: array<non-empty-string, non-empty-string>,
* ttl: int
* }
*/
private function fetchKeysFromUrl(string $url): array
{
try {
$response = $this->client->request(RequestMethod::METHOD_GET, $url, [
'http_errors' => false,
'headers' => [
'Content-Type' => 'Content-Type: application/json; charset=UTF-8',
],
]);
} catch (GuzzleException $e) {
throw FetchingGooglePublicKeysFailed::because("The connection to {$url} failed: ".$e->getMessage(), $e->getCode(), $e);
}
if (($statusCode = $response->getStatusCode()) !== 200) {
throw FetchingGooglePublicKeysFailed::because(
"Unexpected status code {$statusCode} when trying to fetch public keys from {$url}",
);
}
$ttl = preg_match('/max-age=(\d+)/i', $response->getHeaderLine('Cache-Control'), $matches)
? (int) $matches[1]
: 0;
try {
$keys = json_decode((string) $response->getBody(), true, 512, JSON_THROW_ON_ERROR);
} catch (JsonException $e) {
throw FetchingGooglePublicKeysFailed::because('Unexpected response: '.$e->getMessage());
}
if (!is_array($keys)) {
$keys = [];
}
$keys = array_filter($keys, fn(mixed $key): bool => is_string($key));
$keys = array_map(fn(string $key): string => trim($key), $keys);
$keys = array_filter($keys, fn(string $key): bool => $key !== '');
return [
'keys' => $keys,
'ttl' => $ttl,
];
}
}

View File

@@ -0,0 +1,77 @@
<?php
declare(strict_types=1);
namespace Kreait\Firebase\JWT\Action\FetchGooglePublicKeys;
use Kreait\Firebase\JWT\Action\FetchGooglePublicKeys;
use Kreait\Firebase\JWT\Contract\Expirable;
use Kreait\Firebase\JWT\Contract\Keys;
use Kreait\Firebase\JWT\Error\FetchingGooglePublicKeysFailed;
use Psr\Cache\CacheItemPoolInterface;
use Psr\Clock\ClockInterface;
/**
* @internal
*/
final class WithPsr6Cache implements Handler
{
public function __construct(
private readonly Handler $handler,
private readonly CacheItemPoolInterface $cache,
private readonly ClockInterface $clock,
) {
}
public function handle(FetchGooglePublicKeys $action): Keys
{
$now = $this->clock->now();
$cacheKey = md5($action::class);
/** @noinspection PhpUnhandledExceptionInspection */
$cacheItem = $this->cache->getItem($cacheKey);
/** @var Keys|Expirable|null $keys */
$keys = $cacheItem->get();
// We deliberately don't care if the cache item is expired here, as long as the keys
// themselves are not expired
if ($keys instanceof Keys && $keys instanceof Expirable && !$keys->isExpiredAt($now)) {
return $keys;
}
// Non-expiring keys coming from a cache hit can be returned as well
if ($keys instanceof Keys && !($keys instanceof Expirable) && $cacheItem->isHit()) {
return $keys;
}
// At this point, we have to re-fetch the keys, because either the cache item is a miss
// or the value in the cache item is not a Keys object
// We need fresh keys
try {
$keys = $this->handler->handle($action);
} catch (FetchingGooglePublicKeysFailed $e) {
$reason = sprintf(
'The inner handler of %s (%s) failed in fetching keys: %s',
self::class,
$this->handler::class,
$e->getMessage(),
);
throw FetchingGooglePublicKeysFailed::because($reason, $e->getCode(), $e);
}
$cacheItem->set($keys);
if ($keys instanceof Expirable) {
$cacheItem->expiresAt($keys->expiresAt());
} else {
$cacheItem->expiresAfter($action->getFallbackCacheDuration()->value());
}
$this->cache->save($cacheItem);
return $keys;
}
}

View File

@@ -0,0 +1,71 @@
<?php
declare(strict_types=1);
namespace Kreait\Firebase\JWT\Action;
use InvalidArgumentException;
final class VerifyIdToken
{
/**
* @param non-empty-string $token
* @param int<0, max> $leewayInSeconds
* @param non-empty-string|null $expectedTenantId
*/
private function __construct(
private readonly string $token,
private readonly int $leewayInSeconds,
private readonly ?string $expectedTenantId,
) {
}
/**
* @param non-empty-string $token
*/
public static function withToken(string $token): self
{
return new self($token, 0, null);
}
/**
* @param non-empty-string $tenantId
*/
public function withExpectedTenantId(string $tenantId): self
{
return new self($this->token, $this->leewayInSeconds, $tenantId);
}
public function withLeewayInSeconds(int $seconds): self
{
if ($seconds < 0) {
throw new InvalidArgumentException('Leeway must not be negative');
}
return new self($this->token, $seconds, $this->expectedTenantId);
}
/**
* @return non-empty-string
*/
public function token(): string
{
return $this->token;
}
/**
* @return non-empty-string|null
*/
public function expectedTenantId(): ?string
{
return $this->expectedTenantId;
}
/**
* @return int<0, max>
*/
public function leewayInSeconds(): int
{
return $this->leewayInSeconds;
}
}

View File

@@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
namespace Kreait\Firebase\JWT\Action\VerifyIdToken;
use Kreait\Firebase\JWT\Action\VerifyIdToken;
use Kreait\Firebase\JWT\Contract\Token;
use Kreait\Firebase\JWT\Error\IdTokenVerificationFailed;
/**
* @see https://firebase.google.com/docs/auth/admin/verify-id-tokens#verify_id_tokens_using_a_third-party_jwt_library
*/
interface Handler
{
/**
* @throws IdTokenVerificationFailed
*/
public function handle(VerifyIdToken $action): Token;
}

View File

@@ -0,0 +1,203 @@
<?php
declare(strict_types=1);
namespace Kreait\Firebase\JWT\Action\VerifyIdToken;
use Beste\Clock\FrozenClock;
use DateInterval;
use DateTimeImmutable;
use DateTimeInterface;
use Kreait\Firebase\JWT\Action\VerifyIdToken;
use Kreait\Firebase\JWT\Contract\Keys;
use Kreait\Firebase\JWT\Contract\Token;
use Kreait\Firebase\JWT\Error\IdTokenVerificationFailed;
use Kreait\Firebase\JWT\Error\SessionCookieVerificationFailed;
use Kreait\Firebase\JWT\InsecureToken;
use Kreait\Firebase\JWT\SecureToken;
use Kreait\Firebase\JWT\Signer\None;
use Kreait\Firebase\JWT\Token\Parser;
use Kreait\Firebase\JWT\Util;
use Lcobucci\JWT\Encoding\JoseEncoder;
use Lcobucci\JWT\Signer;
use Lcobucci\JWT\Signer\Key\InMemory;
use Lcobucci\JWT\Signer\Rsa\Sha256;
use Lcobucci\JWT\UnencryptedToken;
use Lcobucci\JWT\Validation\Constraint\IssuedBy;
use Lcobucci\JWT\Validation\Constraint\LooseValidAt;
use Lcobucci\JWT\Validation\Constraint\PermittedFor;
use Lcobucci\JWT\Validation\Constraint\SignedWith;
use Lcobucci\JWT\Validation\ConstraintViolation;
use Lcobucci\JWT\Validation\RequiredConstraintsViolated;
use Lcobucci\JWT\Validation\Validator;
use Psr\Clock\ClockInterface;
use Throwable;
use function assert;
use function is_string;
/**
* @internal
*/
final class WithLcobucciJWT implements Handler
{
private readonly Parser $parser;
private readonly Signer $signer;
private readonly Validator $validator;
private readonly bool $isRunOnEmulator;
/**
* @param non-empty-string $projectId
*/
public function __construct(
private readonly string $projectId,
private readonly Keys $keys,
private readonly ClockInterface $clock,
) {
$this->parser = new Parser(new JoseEncoder());
$this->isRunOnEmulator = Util::authEmulatorHost() !== '';
$this->signer = $this->isRunOnEmulator ? new None() : new Sha256();
$this->validator = new Validator();
}
public function handle(VerifyIdToken $action): Token
{
$tokenString = $action->token();
try {
$token = $this->parser->parse($tokenString);
assert($token instanceof UnencryptedToken);
} catch (Throwable $e) {
throw IdTokenVerificationFailed::withTokenAndReasons($tokenString, ['The token is invalid'.$e->getMessage()]);
}
$key = $this->getKey($token);
$clock = FrozenClock::at($this->clock->now());
$leeway = new DateInterval('PT'.$action->leewayInSeconds().'S');
$errors = [];
$constraints = [
new LooseValidAt($clock, $leeway),
new IssuedBy(...["https://securetoken.google.com/{$this->projectId}"]),
new PermittedFor($this->projectId),
];
if ($key !== '' && !$this->isRunOnEmulator) {
$constraints[] = new SignedWith($this->signer, InMemory::plainText($key));
}
try {
$this->validator->assert($token, ...$constraints);
$this->assertUserAuthedAt($token, $clock->now()->add($leeway));
if ($tenantId = $action->expectedTenantId()) {
$this->assertTenantId($token, $tenantId);
}
} catch (RequiredConstraintsViolated $e) {
$errors = array_filter(array_map(
static fn(ConstraintViolation $violation): string => $violation->getMessage(),
$e->violations(),
));
}
if (!empty($errors)) {
throw IdTokenVerificationFailed::withTokenAndReasons($tokenString, $errors);
}
$claims = $token->claims()->all();
foreach ($claims as &$claim) {
if ($claim instanceof DateTimeInterface) {
$claim = $claim->getTimestamp();
}
}
unset($claim);
$headers = $token->headers()->all();
foreach ($headers as &$header) {
if ($header instanceof DateTimeInterface) {
$header = $header->getTimestamp();
}
}
unset($header);
if ($this->isRunOnEmulator) {
return InsecureToken::withValues($tokenString, $headers, $claims);
}
return SecureToken::withValues($tokenString, $headers, $claims);
}
private function getKey(UnencryptedToken $token): string
{
$keys = $this->keys->all();
if ($keys === []) {
throw IdTokenVerificationFailed::withTokenAndReasons($token->toString(), ['No keys are available to verify the tokens signature.']);
}
if ($this->isRunOnEmulator && ($this->signer instanceof None)) {
return '';
}
$keyId = $token->headers()->get('kid');
if (!is_string($keyId) || $keyId === '') {
throw IdTokenVerificationFailed::withTokenAndReasons($token->toString(), ['No key ID was found to verify the signature of this token.']);
}
$key = $keys[$keyId] ?? null;
if ($key === null) {
throw IdTokenVerificationFailed::withTokenAndReasons($token->toString(), ["No public key matching the key ID '{$keyId}' was found to verify the signature of this token."]);
}
return $key;
}
private function assertUserAuthedAt(UnencryptedToken $token, DateTimeInterface $now): void
{
/** @var int|DateTimeImmutable $authTime */
$authTime = $token->claims()->get('auth_time');
if (!$authTime) {
throw RequiredConstraintsViolated::fromViolations(
new ConstraintViolation('The token is missing the "auth_time" claim.'),
);
}
if (is_numeric($authTime)) {
$authTime = new DateTimeImmutable('@'.((int) $authTime));
}
if ($now < $authTime) {
throw RequiredConstraintsViolated::fromViolations(
new ConstraintViolation("The token's user must have authenticated in the past"),
);
}
}
private function assertTenantId(UnencryptedToken $token, string $tenantId): void
{
$claim = (array) $token->claims()->get('firebase', []);
$tenant = $claim['tenant'] ?? null;
if (!is_string($tenant)) {
throw RequiredConstraintsViolated::fromViolations(
new ConstraintViolation('The ID token does not contain a tenant identifier'),
);
}
if ($tenant !== $tenantId) {
throw RequiredConstraintsViolated::fromViolations(
new ConstraintViolation("The token's tenant ID did not match with the expected tenant ID"),
);
}
}
}

View File

@@ -0,0 +1,71 @@
<?php
declare(strict_types=1);
namespace Kreait\Firebase\JWT\Action;
use InvalidArgumentException;
final class VerifySessionCookie
{
/**
* @param non-empty-string $sessionCookie
* @param int<0, max> $leewayInSeconds
* @param non-empty-string|null $expectedTenantId
*/
private function __construct(
private readonly string $sessionCookie,
private readonly int $leewayInSeconds,
private readonly ?string $expectedTenantId,
) {
}
/**
* @param non-empty-string $sessionCookie
*/
public static function withSessionCookie(string $sessionCookie): self
{
return new self($sessionCookie, 0, null);
}
/**
* @param non-empty-string $tenantId
*/
public function withExpectedTenantId(string $tenantId): self
{
return new self($this->sessionCookie, $this->leewayInSeconds, $tenantId);
}
public function withLeewayInSeconds(int $seconds): self
{
if ($seconds < 0) {
throw new InvalidArgumentException('Leeway must not be negative');
}
return new self($this->sessionCookie, $seconds, $this->expectedTenantId);
}
/**
* @return non-empty-string
*/
public function sessionCookie(): string
{
return $this->sessionCookie;
}
/**
* @return non-empty-string|null
*/
public function expectedTenantId(): ?string
{
return $this->expectedTenantId;
}
/**
* @return int<0, max>
*/
public function leewayInSeconds(): int
{
return $this->leewayInSeconds;
}
}

View File

@@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
namespace Kreait\Firebase\JWT\Action\VerifySessionCookie;
use Kreait\Firebase\JWT\Action\VerifySessionCookie;
use Kreait\Firebase\JWT\Contract\Token;
use Kreait\Firebase\JWT\Error\SessionCookieVerificationFailed;
/**
* @see https://firebase.google.com/docs/auth/admin/manage-cookies#verify_session_cookies_using_a_third-party_jwt_library
*/
interface Handler
{
/**
* @throws SessionCookieVerificationFailed
*/
public function handle(VerifySessionCookie $action): Token;
}

View File

@@ -0,0 +1,201 @@
<?php
declare(strict_types=1);
namespace Kreait\Firebase\JWT\Action\VerifySessionCookie;
use Beste\Clock\FrozenClock;
use DateInterval;
use DateTimeImmutable;
use DateTimeInterface;
use Kreait\Firebase\JWT\Action\VerifySessionCookie;
use Kreait\Firebase\JWT\Contract\Keys;
use Kreait\Firebase\JWT\Contract\Token;
use Kreait\Firebase\JWT\Error\SessionCookieVerificationFailed;
use Kreait\Firebase\JWT\InsecureToken;
use Kreait\Firebase\JWT\SecureToken;
use Kreait\Firebase\JWT\Signer\None;
use Kreait\Firebase\JWT\Token\Parser;
use Kreait\Firebase\JWT\Util;
use Lcobucci\JWT\Encoding\JoseEncoder;
use Lcobucci\JWT\Signer;
use Lcobucci\JWT\Signer\Key\InMemory;
use Lcobucci\JWT\Signer\Rsa\Sha256;
use Lcobucci\JWT\UnencryptedToken;
use Lcobucci\JWT\Validation\Constraint\IssuedBy;
use Lcobucci\JWT\Validation\Constraint\LooseValidAt;
use Lcobucci\JWT\Validation\Constraint\PermittedFor;
use Lcobucci\JWT\Validation\Constraint\SignedWith;
use Lcobucci\JWT\Validation\ConstraintViolation;
use Lcobucci\JWT\Validation\RequiredConstraintsViolated;
use Lcobucci\JWT\Validation\Validator;
use Psr\Clock\ClockInterface;
use Throwable;
use function assert;
use function is_string;
/**
* @internal
*/
final class WithLcobucciJWT implements Handler
{
private readonly Parser $parser;
private Signer $signer;
private readonly Validator $validator;
private readonly bool $isRunOnEmulator;
/**
* @param non-empty-string $projectId
*/
public function __construct(
private readonly string $projectId,
private readonly Keys $keys,
private readonly ClockInterface $clock,
) {
$this->parser = new Parser(new JoseEncoder());
$this->isRunOnEmulator = Util::authEmulatorHost() !== '';
$this->signer = $this->isRunOnEmulator ? new None() : new Sha256();
$this->validator = new Validator();
}
public function handle(VerifySessionCookie $action): Token
{
$cookieString = $action->sessionCookie();
try {
$token = $this->parser->parse($cookieString);
assert($token instanceof UnencryptedToken);
} catch (Throwable $e) {
throw SessionCookieVerificationFailed::withSessionCookieAndReasons($cookieString, ['The token is invalid', $e->getMessage()]);
}
$key = $this->getKey($token);
$clock = FrozenClock::at($this->clock->now());
$leeway = new DateInterval('PT'.$action->leewayInSeconds().'S');
$errors = [];
$constraints = [
new LooseValidAt($clock, $leeway),
new IssuedBy(...["https://session.firebase.google.com/{$this->projectId}"]),
new PermittedFor($this->projectId),
];
if ($key !== '' && Util::authEmulatorHost() === '') {
$constraints[] = new SignedWith($this->signer, InMemory::plainText($key));
}
try {
$this->validator->assert($token, ...$constraints);
$this->assertUserAuthedAt($token, $clock->now()->add($leeway));
if ($tenantId = $action->expectedTenantId()) {
$this->assertTenantId($token, $tenantId);
}
} catch (RequiredConstraintsViolated $e) {
$errors = array_map(
static fn(ConstraintViolation $violation): string => '- '.$violation->getMessage(),
$e->violations(),
);
}
if (!empty($errors)) {
throw SessionCookieVerificationFailed::withSessionCookieAndReasons($cookieString, $errors);
}
$claims = $token->claims()->all();
foreach ($claims as &$claim) {
if ($claim instanceof DateTimeInterface) {
$claim = $claim->getTimestamp();
}
}
unset($claim);
$headers = $token->headers()->all();
foreach ($headers as &$header) {
if ($header instanceof DateTimeInterface) {
$header = $header->getTimestamp();
}
}
unset($header);
if (Util::authEmulatorHost() !== '') {
return InsecureToken::withValues($cookieString, $headers, $claims);
}
return SecureToken::withValues($cookieString, $headers, $claims);
}
private function getKey(UnencryptedToken $token): string
{
$keys = $this->keys->all();
if ($keys === []) {
throw SessionCookieVerificationFailed::withSessionCookieAndReasons($token->toString(), ["No keys are available to verify the token's signature."]);
}
if ($this->isRunOnEmulator && ($this->signer instanceof None)) {
return '';
}
$keyId = $token->headers()->get('kid');
if (!is_string($keyId) || $keyId === '') {
throw SessionCookieVerificationFailed::withSessionCookieAndReasons($token->toString(), ["The session cookie doesn't include a `kid` header."]);
}
$key = $keys[$keyId] ?? null;
if ($key === null) {
throw SessionCookieVerificationFailed::withSessionCookieAndReasons($token->toString(), ["The `kid` header of the given token is missing or empty.No public key matching the key ID `{$keyId}` was found to verify the signature of this session cookie."]);
}
return $key;
}
private function assertUserAuthedAt(UnencryptedToken $token, DateTimeInterface $now): void
{
/** @var int|DateTimeImmutable $authTime */
$authTime = $token->claims()->get('auth_time');
if (!$authTime) {
throw RequiredConstraintsViolated::fromViolations(
new ConstraintViolation('The token is missing the "auth_time" claim.'),
);
}
if (is_numeric($authTime)) {
$authTime = new DateTimeImmutable('@'.((int) $authTime));
}
if ($now < $authTime) {
throw RequiredConstraintsViolated::fromViolations(
new ConstraintViolation("The token's user must have authenticated in the past"),
);
}
}
private function assertTenantId(UnencryptedToken $token, string $tenantId): void
{
$claim = (array) $token->claims()->get('firebase', []);
$tenant = $claim['tenant'] ?? null;
if (!is_string($tenant)) {
throw RequiredConstraintsViolated::fromViolations(
new ConstraintViolation('The ID token does not contain a tenant identifier'),
);
}
if ($tenant !== $tenantId) {
throw RequiredConstraintsViolated::fromViolations(
new ConstraintViolation("The token's tenant ID did not match with the expected tenant ID"),
);
}
}
}

View File

@@ -0,0 +1,17 @@
<?php
declare(strict_types=1);
namespace Kreait\Firebase\JWT\Contract;
use DateTimeImmutable;
use DateTimeInterface;
interface Expirable
{
public function withExpirationTime(DateTimeImmutable $time): self;
public function isExpiredAt(DateTimeInterface $now): bool;
public function expiresAt(): DateTimeImmutable;
}

View File

@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace Kreait\Firebase\JWT\Contract;
use DateTimeImmutable;
use DateTimeInterface;
trait ExpirableTrait
{
private DateTimeImmutable $expirationTime;
public function withExpirationTime(DateTimeImmutable $expirationTime): self
{
$expirable = clone $this;
$expirable->expirationTime = $expirationTime;
return $expirable;
}
public function isExpiredAt(DateTimeInterface $now): bool
{
return $this->expirationTime < $now;
}
public function expiresAt(): DateTimeImmutable
{
return $this->expirationTime;
}
}

View File

@@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
namespace Kreait\Firebase\JWT\Contract;
interface Keys
{
/**
* @return array<non-empty-string, non-empty-string>
*/
public function all(): array;
}

View File

@@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
namespace Kreait\Firebase\JWT\Contract;
trait KeysTrait
{
/** @var array<non-empty-string, non-empty-string> */
private array $values = [];
/**
* @return array<non-empty-string, non-empty-string>
*/
public function all(): array
{
return $this->values;
}
}

View File

@@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace Kreait\Firebase\JWT\Contract;
interface Token
{
public function __toString(): string;
/**
* @return array<string, mixed>
*/
public function headers(): array;
/**
* @return array<string, mixed>
*/
public function payload(): array;
public function toString(): string;
}

View File

@@ -0,0 +1,75 @@
<?php
declare(strict_types=1);
namespace Kreait\Firebase\JWT;
use Beste\Clock\SystemClock;
use DateInterval;
use Kreait\Firebase\JWT\Action\CreateCustomToken;
use Kreait\Firebase\JWT\Action\CreateCustomToken\Handler;
use Kreait\Firebase\JWT\Action\CreateCustomToken\WithLcobucciJWT;
use Kreait\Firebase\JWT\Contract\Token;
use Kreait\Firebase\JWT\Error\CustomTokenCreationFailed;
use Kreait\Firebase\JWT\Value\Duration;
final class CustomTokenGenerator
{
private ?string $tenantId = null;
public function __construct(private readonly Handler $handler)
{
}
/**
* @param non-empty-string $clientEmail
* @param non-empty-string $privateKey
*/
public static function withClientEmailAndPrivateKey(string $clientEmail, string $privateKey): self
{
$handler = new WithLcobucciJWT($clientEmail, $privateKey, SystemClock::create());
return new self($handler);
}
/**
* @param non-empty-string $tenantId
*/
public function withTenantId(string $tenantId): self
{
$generator = clone $this;
$generator->tenantId = $tenantId;
return $generator;
}
public function execute(CreateCustomToken $action): Token
{
if ($this->tenantId) {
$action = $action->withTenantId($this->tenantId);
}
return $this->handler->handle($action);
}
/**
* @param non-empty-string $uid
* @param array<non-empty-string, mixed>|null $claims
*
* @throws CustomTokenCreationFailed
*/
public function createCustomToken(string $uid, array|null $claims = null, Duration|DateInterval|string|int|null $timeToLive = null): Token
{
$action = CreateCustomToken::forUid($uid);
if ($claims !== null) {
$action = $action->withCustomClaims($claims);
}
if ($timeToLive !== null) {
$action = $action->withTimeToLive($timeToLive);
}
return $this->execute($action);
}
}

View File

@@ -0,0 +1,18 @@
<?php
declare(strict_types=1);
namespace Kreait\Firebase\JWT\Error;
use RuntimeException;
use Throwable;
final class CustomTokenCreationFailed extends RuntimeException
{
public static function because(string $reason, ?int $code = null, ?Throwable $previous = null): self
{
$code = $code ?: 0;
return new self($reason, $code, $previous);
}
}

View File

@@ -0,0 +1,18 @@
<?php
declare(strict_types=1);
namespace Kreait\Firebase\JWT\Error;
use RuntimeException;
use Throwable;
final class FetchingGooglePublicKeysFailed extends RuntimeException
{
public static function because(string $reason, ?int $code = null, ?Throwable $previous = null): self
{
$code = $code ?: 0;
return new self($reason, $code, $previous);
}
}

View File

@@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
namespace Kreait\Firebase\JWT\Error;
use RuntimeException;
use const PHP_EOL;
final class IdTokenVerificationFailed extends RuntimeException
{
/**
* @param array<non-empty-string> $reasons
*/
public static function withTokenAndReasons(string $token, array $reasons): self
{
if (mb_strlen($token) > 18) {
$token = mb_substr($token, 0, 15).'...';
}
$summary = implode(PHP_EOL.'- ', $reasons);
$message = "The value '{$token}' is not a verified ID token:".PHP_EOL.'- '.$summary.PHP_EOL;
return new self($message);
}
}

View File

@@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
namespace Kreait\Firebase\JWT\Error;
use RuntimeException;
use const PHP_EOL;
final class SessionCookieVerificationFailed extends RuntimeException
{
/**
* @param array<int|string, string> $reasons
*/
public static function withSessionCookieAndReasons(string $token, array $reasons): self
{
if (mb_strlen($token) > 18) {
$token = mb_substr($token, 0, 15).'...';
}
$summary = implode(PHP_EOL.'- ', $reasons);
$message = "The value '{$token}' is not a verified session cookie:".PHP_EOL.'- '.$summary.PHP_EOL;
return new self($message);
}
}

View File

@@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
namespace Kreait\Firebase\JWT;
use Beste\Clock\SystemClock;
use GuzzleHttp\Client;
use Kreait\Firebase\JWT\Action\FetchGooglePublicKeys;
use Kreait\Firebase\JWT\Action\FetchGooglePublicKeys\Handler;
use Kreait\Firebase\JWT\Action\FetchGooglePublicKeys\WithGuzzle;
use Kreait\Firebase\JWT\Contract\Expirable;
use Kreait\Firebase\JWT\Contract\Keys;
use Psr\Clock\ClockInterface;
final class GooglePublicKeys implements Keys
{
private readonly ClockInterface $clock;
private readonly Handler $handler;
private ?Keys $keys = null;
public function __construct(?Handler $handler = null, ?ClockInterface $clock = null)
{
$this->clock = $clock ?: SystemClock::create();
$this->handler = $handler ?: new WithGuzzle(new Client(['http_errors' => false]), $this->clock);
}
public function all(): array
{
$keysAreThereButExpired = $this->keys instanceof Expirable && $this->keys->isExpiredAt($this->clock->now());
if (!$this->keys || $keysAreThereButExpired) {
$this->keys = $this->handler->handle(FetchGooglePublicKeys::fromGoogle());
// There is a small chance that we get keys that are already expired, but at this point we're happy
// that we got keys at all. The next time this method gets called, we will re-fetch.
}
return $this->keys->all();
}
}

View File

@@ -0,0 +1,101 @@
<?php
declare(strict_types=1);
namespace Kreait\Firebase\JWT;
use Beste\Clock\SystemClock;
use GuzzleHttp\Client;
use InvalidArgumentException;
use Kreait\Firebase\JWT\Action\FetchGooglePublicKeys\WithGuzzle;
use Kreait\Firebase\JWT\Action\FetchGooglePublicKeys\WithPsr6Cache;
use Kreait\Firebase\JWT\Action\VerifyIdToken;
use Kreait\Firebase\JWT\Action\VerifyIdToken\Handler;
use Kreait\Firebase\JWT\Action\VerifyIdToken\WithLcobucciJWT;
use Kreait\Firebase\JWT\Contract\Token;
use Kreait\Firebase\JWT\Error\IdTokenVerificationFailed;
use Psr\Cache\CacheItemPoolInterface;
final class IdTokenVerifier
{
/**
* @var non-empty-string|null
*/
private ?string $expectedTenantId = null;
public function __construct(private readonly Handler $handler)
{
}
/**
* @param non-empty-string $projectId
*/
public static function createWithProjectId(string $projectId): self
{
$clock = SystemClock::create();
$keyHandler = new WithGuzzle(new Client(['http_errors' => false]), $clock);
$keys = new GooglePublicKeys($keyHandler, $clock);
$handler = new WithLcobucciJWT($projectId, $keys, $clock);
return new self($handler);
}
/**
* @param non-empty-string $projectId
*/
public static function createWithProjectIdAndCache(string $projectId, CacheItemPoolInterface $cache): self
{
$clock = SystemClock::create();
$innerKeyHandler = new WithGuzzle(new Client(['http_errors' => false]), $clock);
$keyHandler = new WithPsr6Cache($innerKeyHandler, $cache, $clock);
$keys = new GooglePublicKeys($keyHandler, $clock);
$handler = new WithLcobucciJWT($projectId, $keys, $clock);
return new self($handler);
}
/**
* @param non-empty-string $tenantId
*/
public function withExpectedTenantId(string $tenantId): self
{
$verifier = clone $this;
$verifier->expectedTenantId = $tenantId;
return $verifier;
}
public function execute(VerifyIdToken $action): Token
{
if ($this->expectedTenantId) {
$action = $action->withExpectedTenantId($this->expectedTenantId);
}
return $this->handler->handle($action);
}
/**
* @param non-empty-string $token
*
* @throws IdTokenVerificationFailed
*/
public function verifyIdToken(string $token): Token
{
return $this->execute(VerifyIdToken::withToken($token));
}
/**
* @param non-empty-string $token
* @param int<0, max> $leewayInSeconds
*
* @throws IdTokenVerificationFailed
* @throws InvalidArgumentException on invalid leeway
*/
public function verifyIdTokenWithLeeway(string $token, int $leewayInSeconds): Token
{
return $this->execute(VerifyIdToken::withToken($token)->withLeewayInSeconds($leewayInSeconds));
}
}

View File

@@ -0,0 +1,65 @@
<?php
declare(strict_types=1);
namespace Kreait\Firebase\JWT;
use Kreait\Firebase\JWT\Contract\Token;
use Stringable;
final class InsecureToken implements Token, Stringable
{
/**
* @param non-empty-string $encodedString
* @param array<non-empty-string, mixed> $headers
* @param array<non-empty-string, mixed> $payload
*/
private function __construct(
private readonly string $encodedString,
private readonly array $headers,
private readonly array $payload,
) {
}
/**
* @return non-empty-string
*/
public function __toString(): string
{
return $this->encodedString;
}
/**
* @param non-empty-string $encodedString
* @param array<non-empty-string, mixed> $headers
* @param array<non-empty-string, mixed> $payload
*/
public static function withValues(string $encodedString, array $headers, array $payload): self
{
return new self($encodedString, $headers, $payload);
}
/**
* @return array<non-empty-string, mixed>
*/
public function headers(): array
{
return $this->headers;
}
/**
* @return array<non-empty-string, mixed>
*/
public function payload(): array
{
return $this->payload;
}
/**
* @return non-empty-string
*/
public function toString(): string
{
return $this->encodedString;
}
}

View File

@@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
namespace Kreait\Firebase\JWT\Keys;
use DateTimeImmutable;
use Kreait\Firebase\JWT\Contract\Expirable;
use Kreait\Firebase\JWT\Contract\ExpirableTrait;
use Kreait\Firebase\JWT\Contract\Keys;
use Kreait\Firebase\JWT\Contract\KeysTrait;
/**
* @internal
*/
final class ExpiringKeys implements Expirable, Keys
{
use KeysTrait;
use ExpirableTrait;
private function __construct()
{
$this->expirationTime = new DateTimeImmutable('0001-01-01'); // Very distant past :)
}
/**
* @param array<non-empty-string, non-empty-string> $values
*/
public static function withValuesAndExpirationTime(array $values, DateTimeImmutable $expirationTime): self
{
$keys = new self();
$keys->values = $values;
$keys->expirationTime = $expirationTime;
return $keys;
}
}

View File

@@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
namespace Kreait\Firebase\JWT\Keys;
use Kreait\Firebase\JWT\Contract\Keys;
use Kreait\Firebase\JWT\Contract\KeysTrait;
/**
* @internal
*/
final class StaticKeys implements Keys
{
use KeysTrait;
private function __construct()
{
}
public static function empty(): self
{
return new self();
}
/**
* @param array<non-empty-string, non-empty-string> $values
*/
public static function withValues(array $values): self
{
$keys = new self();
$keys->values = $values;
return $keys;
}
}

View File

@@ -0,0 +1,57 @@
<?php
declare(strict_types=1);
namespace Kreait\Firebase\JWT;
use Kreait\Firebase\JWT\Contract\Token;
use Stringable;
final class SecureToken implements Token, Stringable
{
/**
* @param array<string, mixed> $headers
* @param array<string, mixed> $payload
*/
private function __construct(
private readonly string $encodedString,
private readonly array $headers,
private readonly array $payload,
) {
}
public function __toString(): string
{
return $this->encodedString;
}
/**
* @param array<string, mixed> $headers
* @param array<string, mixed> $payload
*/
public static function withValues(string $encodedString, array $headers, array $payload): self
{
return new self($encodedString, $headers, $payload);
}
/**
* @return array<string, mixed>
*/
public function headers(): array
{
return $this->headers;
}
/**
* @return array<string, mixed>
*/
public function payload(): array
{
return $this->payload;
}
public function toString(): string
{
return $this->encodedString;
}
}

View File

@@ -0,0 +1,101 @@
<?php
declare(strict_types=1);
namespace Kreait\Firebase\JWT;
use Beste\Clock\SystemClock;
use GuzzleHttp\Client;
use InvalidArgumentException;
use Kreait\Firebase\JWT\Action\FetchGooglePublicKeys\WithGuzzle;
use Kreait\Firebase\JWT\Action\FetchGooglePublicKeys\WithPsr6Cache;
use Kreait\Firebase\JWT\Action\VerifySessionCookie;
use Kreait\Firebase\JWT\Action\VerifySessionCookie\Handler;
use Kreait\Firebase\JWT\Action\VerifySessionCookie\WithLcobucciJWT;
use Kreait\Firebase\JWT\Contract\Token;
use Kreait\Firebase\JWT\Error\SessionCookieVerificationFailed;
use Psr\Cache\CacheItemPoolInterface;
final class SessionCookieVerifier
{
/**
* @var non-empty-string|null
*/
private ?string $expectedTenantId = null;
public function __construct(private readonly Handler $handler)
{
}
/**
* @param non-empty-string $projectId
*/
public static function createWithProjectId(string $projectId): self
{
$clock = SystemClock::create();
$keyHandler = new WithGuzzle(new Client(['http_errors' => false]), $clock);
$keys = new GooglePublicKeys($keyHandler, $clock);
$handler = new WithLcobucciJWT($projectId, $keys, $clock);
return new self($handler);
}
/**
* @param non-empty-string $projectId
*/
public static function createWithProjectIdAndCache(string $projectId, CacheItemPoolInterface $cache): self
{
$clock = SystemClock::create();
$innerKeyHandler = new WithGuzzle(new Client(['http_errors' => false]), $clock);
$keyHandler = new WithPsr6Cache($innerKeyHandler, $cache, $clock);
$keys = new GooglePublicKeys($keyHandler, $clock);
$handler = new WithLcobucciJWT($projectId, $keys, $clock);
return new self($handler);
}
/**
* @param non-empty-string $tenantId
*/
public function withExpectedTenantId(string $tenantId): self
{
$generator = clone $this;
$generator->expectedTenantId = $tenantId;
return $generator;
}
public function execute(VerifySessionCookie $action): Token
{
if ($this->expectedTenantId) {
$action = $action->withExpectedTenantId($this->expectedTenantId);
}
return $this->handler->handle($action);
}
/**
* @param non-empty-string $sessionCookie
*
* @throws SessionCookieVerificationFailed
*/
public function verifySessionCookie(string $sessionCookie): Token
{
return $this->execute(VerifySessionCookie::withSessionCookie($sessionCookie));
}
/**
* @param non-empty-string $sessionCookie
* @param int<0, max> $leewayInSeconds
*
* @throws InvalidArgumentException on invalid leeway
* @throws SessionCookieVerificationFailed
*/
public function verifySessionCookieWithLeeway(string $sessionCookie, int $leewayInSeconds): Token
{
return $this->execute(VerifySessionCookie::withSessionCookie($sessionCookie)->withLeewayInSeconds($leewayInSeconds));
}
}

View File

@@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
namespace Kreait\Firebase\JWT\Signer;
use Lcobucci\JWT\Signer;
use Lcobucci\JWT\Signer\Key;
final class None implements Signer
{
public function algorithmId(): string
{
return 'none';
}
public function sign(string $payload, Key $key): string
{
// @phpstan-ignore-next-line
return '';
}
public function verify(string $expected, string $payload, Key $key): bool
{
// @phpstan-ignore-next-line
return $expected === '';
}
}

View File

@@ -0,0 +1,178 @@
<?php
declare(strict_types=1);
namespace Kreait\Firebase\JWT\Token;
use DateTimeImmutable;
use Lcobucci\JWT\Decoder;
use Lcobucci\JWT\Parser as ParserInterface;
use Lcobucci\JWT\Token;
use Lcobucci\JWT\Token\DataSet;
use Lcobucci\JWT\Token\InvalidTokenStructure;
use Lcobucci\JWT\Token\Plain;
use Lcobucci\JWT\Token\RegisteredClaims;
use Lcobucci\JWT\Token\Signature;
use Lcobucci\JWT\Token\UnsupportedHeaderFound;
use function array_key_exists;
use function is_array;
/**
* This is an almost 1:1 copy of the parser in `lcobucci/jwt`, with only the signature verification
* removed; hence the name `InsecureParser`.
*
* @internal
*/
final class InsecureParser implements ParserInterface
{
private const MICROSECOND_PRECISION = 6;
public function __construct(private readonly Decoder $decoder)
{
}
/**
* @param non-empty-string $jwt
*/
public function parse(string $jwt): Token
{
[$encodedHeaders, $encodedClaims] = $this->splitJwt($jwt);
if ($encodedHeaders === '') {
throw new InvalidTokenStructure('The JWT string is missing the Header part');
}
if ($encodedClaims === '') {
throw new InvalidTokenStructure('The JWT string is missing the Claim part');
}
$header = $this->parseHeader($encodedHeaders);
return new Plain(
new DataSet($header, $encodedHeaders),
new DataSet($this->parseClaims($encodedClaims), $encodedClaims),
new Signature('none', 'none'),
);
}
/**
* Splits the JWT string into an array.
*
* @param non-empty-string $jwt
*
* @throws InvalidTokenStructure when JWT doesn't have all parts
*
* @return string[]
*/
private function splitJwt(string $jwt): array
{
return explode('.', $jwt);
}
/**
* Parses the header from a string.
*
* @param non-empty-string $data
*
* @throws InvalidTokenStructure when parsed content isn't an array
* @throws UnsupportedHeaderFound when an invalid header is informed
*
* @return array<non-empty-string, mixed>
*/
private function parseHeader(string $data): array
{
$header = $this->decoder->jsonDecode($this->decoder->base64UrlDecode($data));
if (!is_array($header)) {
throw InvalidTokenStructure::arrayExpected('headers');
}
$this->guardAgainstEmptyStringKeys($header, 'headers');
if (array_key_exists('enc', $header)) {
throw UnsupportedHeaderFound::encryption();
}
if (!array_key_exists('typ', $header)) {
$header['typ'] = 'JWT';
}
return $header;
}
/**
* Parses the claim set from a string.
*
* @param non-empty-string $data
*
* @throws InvalidTokenStructure when parsed content isn't an array or contains non-parseable dates
*
* @return array<non-empty-string, mixed>
*/
private function parseClaims(string $data): array
{
$claims = $this->decoder->jsonDecode($this->decoder->base64UrlDecode($data));
if (!is_array($claims)) {
throw InvalidTokenStructure::arrayExpected('claims');
}
$this->guardAgainstEmptyStringKeys($claims, 'claims');
if (array_key_exists(RegisteredClaims::AUDIENCE, $claims)) {
$claims[RegisteredClaims::AUDIENCE] = (array) $claims[RegisteredClaims::AUDIENCE];
}
foreach (RegisteredClaims::DATE_CLAIMS as $claim) {
if (!array_key_exists($claim, $claims)) {
continue;
}
if (!is_scalar($claims[$claim])) {
continue;
}
if (is_bool($claims[$claim])) {
continue;
}
$claims[$claim] = $this->convertDate($claims[$claim]);
}
return $claims;
}
/**
* @param array<array-key, mixed> $array
* @param non-empty-string $part
*
* @phpstan-assert array<non-empty-string, mixed> $array
*/
private function guardAgainstEmptyStringKeys(array $array, string $part): void
{
foreach ($array as $key => $value) {
if ($key === '') {
throw InvalidTokenStructure::arrayExpected($part);
}
}
}
/** @throws InvalidTokenStructure */
private function convertDate(int|float|string $timestamp): DateTimeImmutable
{
if (!is_numeric($timestamp)) {
throw InvalidTokenStructure::dateIsNotParseable($timestamp);
}
$normalizedTimestamp = number_format((float) $timestamp, self::MICROSECOND_PRECISION, '.', '');
$date = DateTimeImmutable::createFromFormat('U.u', $normalizedTimestamp);
if ($date === false) {
throw InvalidTokenStructure::dateIsNotParseable($normalizedTimestamp);
}
return $date;
}
}

View File

@@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
namespace Kreait\Firebase\JWT\Token;
use Kreait\Firebase\JWT\Util;
use Lcobucci\JWT\Decoder;
use Lcobucci\JWT\Parser as ParserInterface;
use Lcobucci\JWT\Token;
use Lcobucci\JWT\Token\Parser as SecureParser;
final class Parser implements ParserInterface
{
private ParserInterface $parser;
public function __construct(public readonly Decoder $decoder)
{
if (Util::authEmulatorHost() !== '') {
$this->parser = new InsecureParser($decoder);
} else {
$this->parser = new SecureParser($decoder);
}
}
public function parse(string $jwt): Token
{
return $this->parser->parse($jwt);
}
}

View File

@@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
namespace Kreait\Firebase\JWT;
use function in_array;
/**
* @internal
*/
final class Util
{
/**
* @param non-empty-string $name
*/
public static function getenv(string $name): ?string
{
$value = $_SERVER[$name] ?? $_ENV[$name] ?? getenv($name);
if (is_string($value)) {
return $value;
}
return null;
}
public static function authEmulatorHost(): string
{
$emulatorHost = self::getenv('FIREBASE_AUTH_EMULATOR_HOST');
if (!in_array($emulatorHost, [null, ''], true)) {
return $emulatorHost;
}
return '';
}
}

View File

@@ -0,0 +1,187 @@
<?php
declare(strict_types=1);
namespace Kreait\Firebase\JWT\Value;
use DateInterval;
use DateTimeImmutable;
use InvalidArgumentException;
use Stringable;
use Throwable;
use function is_int;
/**
* Adapted duration class from gamez/duration.
*
* @see https://github.com/jeromegamez/duration-php
*/
final class Duration implements Stringable
{
public const NONE = 'PT0S';
private function __construct(private readonly DateInterval $value)
{
}
public function __toString(): string
{
return $this->toString();
}
public static function make(self|DateInterval|int|string $value): self
{
if ($value instanceof self) {
return $value;
}
if ($value instanceof DateInterval) {
return self::fromDateInterval($value);
}
if (is_int($value)) {
return self::inSeconds($value);
}
if (mb_strpos($value, 'P') === 0) {
return self::fromDateIntervalSpec($value);
}
try {
$interval = DateInterval::createFromDateString($value);
} catch (Throwable) {
throw new InvalidArgumentException("Unable to determine a duration from '{$value}'");
}
if ($interval === false) {
throw new InvalidArgumentException("Unable to determine a duration from '{$value}'");
}
$duration = self::fromDateInterval($interval);
// If the string doesn't contain a zero, but the result equals to zero
// the value must be invalid.
if (mb_strpos($value, '0') !== false) {
return $duration;
}
if (!$duration->equals(self::none())) {
return $duration;
}
throw new InvalidArgumentException("Unable to determine a duration from '{$value}'");
}
/**
* @throws InvalidArgumentException
*/
public static function inSeconds(int $seconds): self
{
if ($seconds < 0) {
throw new InvalidArgumentException('A duration can not be negative');
}
return self::fromDateIntervalSpec('PT'.$seconds.'S');
}
/**
* @throws InvalidArgumentException
*/
public static function fromDateIntervalSpec(string $spec): self
{
try {
$interval = new DateInterval($spec);
} catch (Throwable) {
throw new InvalidArgumentException("'{$spec}' is not a valid DateInterval specification");
}
return self::fromDateInterval($interval);
}
public static function fromDateInterval(DateInterval $interval): self
{
$now = new DateTimeImmutable();
$then = $now->add($interval);
if ($then < $now) {
throw new InvalidArgumentException('A duration can not be negative');
}
return new self($interval);
}
public static function none(): self
{
return self::fromDateIntervalSpec(self::NONE);
}
public function value(): DateInterval
{
return $this->value;
}
public function isLargerThan(self|DateInterval|int|string $other): bool
{
return 1 === $this->compareTo($other);
}
public function equals(self|DateInterval|int|string $other): bool
{
return 0 === $this->compareTo($other);
}
public function isSmallerThan(self|DateInterval|int|string $other): bool
{
return -1 === $this->compareTo($other);
}
public function compareTo(self|DateInterval|int|string $other): int
{
$other = self::make($other);
$now = self::now();
return $now->add($this->value) <=> $now->add($other->value);
}
public function toString(): string
{
return self::toDateIntervalSpec(self::normalizeInterval($this->value));
}
private static function now(): DateTimeImmutable
{
return new DateTimeImmutable('@'.time());
}
private static function normalizeInterval(DateInterval $value): DateInterval
{
$now = self::now();
$then = $now->add($value);
return $now->diff($then);
}
private static function toDateIntervalSpec(DateInterval $value): string
{
$spec = 'P';
$spec .= 0 !== $value->y ? $value->y.'Y' : '';
$spec .= 0 !== $value->m ? $value->m.'M' : '';
$spec .= 0 !== $value->d ? $value->d.'D' : '';
$spec .= 'T';
$spec .= 0 !== $value->h ? $value->h.'H' : '';
$spec .= 0 !== $value->i ? $value->i.'M' : '';
$spec .= 0 !== $value->s ? $value->s.'S' : '';
if ('T' === mb_substr($spec, -1)) {
$spec = mb_substr($spec, 0, -1);
}
if ('P' === $spec) {
return self::NONE;
}
return $spec;
}
}