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,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;
}
}