Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,12 @@ Please read about the future of the Firebase Admin PHP SDK on the

## [Unreleased]

### Changed

* This release introduces [Valinor](https://valinor.cuyz.io/) for type-safe object mapping. The first application is
mapping a given service account file, JSON, or array to the newly added internal `ServiceAccount` class, with more
to follow in future releases.

## [7.20.0] - 2025-07-18

### Added
Expand Down
1 change: 1 addition & 0 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
"beste/clock": "^3.0",
"beste/in-memory-cache": "^1.3.1",
"beste/json": "^1.5.1",
"cuyz/valinor": "^2.0",
"fig/http-message-util": "^1.1.5",
"firebase/php-jwt": "^6.10.2",
"google/auth": "^v1.45",
Expand Down
1 change: 1 addition & 0 deletions phpstan.neon.dist
Original file line number Diff line number Diff line change
Expand Up @@ -28,4 +28,5 @@ parameters:
reportPossiblyNonexistentConstantArrayOffset: true
treatPhpDocTypesAsCertain: false
includes:
- vendor/cuyz/valinor/qa/PHPStan/valinor-phpstan-configuration.php
- vendor/phpstan/phpstan/conf/bleedingEdge.neon
178 changes: 85 additions & 93 deletions src/Firebase/Factory.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@
use Beste\Cache\InMemoryCache;
use Beste\Clock\SystemClock;
use Beste\Clock\WrappingClock;
use Beste\Json;
use Firebase\JWT\CachedKeySet;
use Google\Auth\ApplicationDefaultCredentials;
use Google\Auth\Credentials\ServiceAccountCredentials;
Expand Down Expand Up @@ -38,28 +37,22 @@
use Kreait\Firebase\JWT\SessionCookieVerifier;
use Kreait\Firebase\Messaging\AppInstanceApiClient;
use Kreait\Firebase\Messaging\RequestFactory;
use Kreait\Firebase\Valinor\Mapper;
use Kreait\Firebase\Valinor\Normalizer;
use Kreait\Firebase\Valinor\Source;
use Psr\Cache\CacheItemPoolInterface;
use Psr\Clock\ClockInterface;
use Psr\Http\Message\UriInterface;
use Psr\Log\LoggerInterface;
use Psr\Log\LogLevel;
use Stringable;
use Throwable;
use UnexpectedValueException;

use function array_filter;
use function is_string;
use function sprintf;
use function trim;

/**
* @phpstan-type ServiceAccountShape array{
* project_id: non-empty-string,
* client_email: non-empty-string,
* private_key: non-empty-string,
* type: 'service_account'
* }
*/
final class Factory
{
public const API_CLIENT_SCOPES = [
Expand All @@ -83,10 +76,7 @@ final class Factory
*/
private ?string $defaultStorageBucket = null;

/**
* @var ServiceAccountShape|null
*/
private ?array $serviceAccount = null;
private ?ServiceAccount $serviceAccount = null;

private ?FetchAuthTokenInterface $googleAuthTokenCredentials = null;

Expand Down Expand Up @@ -134,6 +124,14 @@ final class Factory
*/
private array $firestoreClientConfig = [];

private mixed $mapperCache = null;

private mixed $normalizerCache = null;

private ?Mapper $mapper = null;

private ?Normalizer $normalizer = null;

public function __construct()
{
$this->clock = SystemClock::create();
Expand All @@ -144,40 +142,16 @@ public function __construct()
}

/**
* @param non-empty-string|ServiceAccountShape $value
* @param string|array<mixed> $value
*
* @throws InvalidArgumentException
*/
public function withServiceAccount(string|array $value): self
{
if (is_string($value) && str_starts_with($value, '{')) {
try {
/** @var ServiceAccountShape $serviceAccount */
$serviceAccount = Json::decode($value, true);
} catch (UnexpectedValueException $e) {
throw new InvalidArgumentException('Invalid service account: '.$e->getMessage(), $e->getCode(), $e);
}

$factory = clone $this;
$factory->serviceAccount = $serviceAccount;

return $factory;
}

if (is_string($value)) {
try {
/** @var ServiceAccountShape $serviceAccount */
$serviceAccount = Json::decodeFile($value, true);

$factory = clone $this;
$factory->serviceAccount = $serviceAccount;

return $factory;
} catch (UnexpectedValueException $e) {
throw new InvalidArgumentException('Invalid service account: '.$e->getMessage(), $e->getCode(), $e);
}
}
$serviceAccount = $this->mapServiceAccount($value);

$factory = clone $this;
$factory->serviceAccount = $value;
$factory->serviceAccount = $serviceAccount;

return $factory;
}
Expand Down Expand Up @@ -308,6 +282,24 @@ public function withKeySetCache(CacheItemPoolInterface $cache): self
return $factory;
}

public function withMapperCache(mixed $cache): self
{
$factory = clone $this;
$factory->mapperCache = $cache;
$factory->mapper = null;

return $factory;
}

public function withNormalizerCache(mixed $cache): self
{
$factory = clone $this;
$factory->normalizerCache = $cache;
$factory->normalizer = null;

return $factory;
}

public function withHttpClientOptions(HttpClientOptions $options): self
{
$factory = clone $this;
Expand Down Expand Up @@ -385,8 +377,8 @@ public function createAppCheck(): Contract\AppCheck
return new AppCheck(
new AppCheck\ApiClient($http),
new AppCheckTokenGenerator(
$serviceAccount['client_email'],
$serviceAccount['private_key'],
$serviceAccount->clientEmail,
$serviceAccount->privateKey,
$this->clock,
),
new AppCheckTokenVerifier($projectId, $keySet),
Expand Down Expand Up @@ -519,16 +511,7 @@ public function createStorage(): Contract\Storage
* @deprecated 7.20.0
* @codeCoverageIgnore
*
* @return array{
* credentialsType: string|null,
* databaseUrl: string,
* defaultStorageBucket: string|null,
* serviceAccount: string|array<string, string>|null,
* projectId: string,
* tenantId: non-empty-string|null,
* tokenCacheType: class-string,
* verifierCacheType: class-string,
* }
* @return array<mixed>
*/
public function getDebugInfo(): array
{
Expand Down Expand Up @@ -616,13 +599,7 @@ public function createApiClient(?array $config = null, ?array $middlewares = nul
}

/**
* @return array{
* projectId: non-empty-string,
* authCache: CacheItemPoolInterface,
* credentialsFetcher?: FetchAuthTokenInterface,
* keyFile?: ServiceAccountShape,
* keyFilePath?: non-empty-string
* }
* @return array<non-empty-string, mixed>
*/
private function googleCloudClientConfig(): array
{
Expand All @@ -638,7 +615,7 @@ private function googleCloudClientConfig(): array

$serviceAccount = $this->getServiceAccount();
if ($serviceAccount !== null) {
$config['keyFile'] = $serviceAccount;
$config['keyFile'] = $this->normalizeServiceAccount($serviceAccount);
}

return $config;
Expand All @@ -658,6 +635,8 @@ private function getProjectId(): string
? $credentials->getProjectId()
: Util::getenv('GOOGLE_CLOUD_PROJECT');

$projectId ??= $this->getServiceAccount()?->projectId;

if (is_string($projectId) && $projectId !== '') {
return $this->projectId = $projectId;
}
Expand All @@ -670,23 +649,15 @@ private function getProjectId(): string
*/
private function getDatabaseUrl(): string
{
if ($this->databaseUrl === null) {
$this->databaseUrl = sprintf('https://%s.firebaseio.com', $this->getProjectId());
}

return $this->databaseUrl;
return $this->databaseUrl ??= sprintf('https://%s.firebaseio.com', $this->getProjectId());
}

/**
* @return non-empty-string
*/
private function getStorageBucketName(): string
{
if ($this->defaultStorageBucket === null) {
$this->defaultStorageBucket = sprintf('%s.appspot.com', $this->getProjectId());
}

return $this->defaultStorageBucket;
return $this->defaultStorageBucket ??= sprintf('%s.appspot.com', $this->getProjectId());
}

private function createCustomTokenGenerator(): ?CustomTokenViaGoogleCredentials
Expand All @@ -711,46 +682,49 @@ private function createIdTokenVerifier(): IdTokenVerifier
return $verifier->withExpectedTenantId($this->tenantId);
}

private function createSessionCookieVerifier(): SessionCookieVerifier
private function getMapper(): Mapper
{
return SessionCookieVerifier::createWithProjectIdAndCache($this->getProjectId(), $this->verifierCache ?? $this->defaultCache);
return $this->mapper ??= new Mapper($this->mapperCache);
}

/**
* @return ServiceAccountShape|null
*/
private function getServiceAccount(): ?array
private function getNormalizer(): Normalizer
{
if ($this->serviceAccount === null) {
$googleApplicationCredentials = Util::getenv('GOOGLE_APPLICATION_CREDENTIALS');
return $this->normalizer ??= new Normalizer($this->normalizerCache);
}

if ($googleApplicationCredentials === null) {
return null;
}
private function createSessionCookieVerifier(): SessionCookieVerifier
{
return SessionCookieVerifier::createWithProjectIdAndCache($this->getProjectId(), $this->verifierCache ?? $this->defaultCache);
}

if (!str_starts_with($googleApplicationCredentials, '{')) {
return null;
}
private function getServiceAccount(): ?ServiceAccount
{
if ($this->serviceAccount !== null) {
return $this->serviceAccount;
}

/** @var ServiceAccountShape $serviceAccount */
$serviceAccount = Json::decode($googleApplicationCredentials, true);
$googleApplicationCredentials = Util::getenv('GOOGLE_APPLICATION_CREDENTIALS');

$this->serviceAccount = $serviceAccount;
if ($googleApplicationCredentials === null) {
return $this->serviceAccount;
}

return $this->serviceAccount;
return $this->serviceAccount = $this->mapServiceAccount($googleApplicationCredentials);
}

private function getGoogleAuthTokenCredentials(): ?FetchAuthTokenInterface
{
if ($this->googleAuthTokenCredentials !== null) {
if ($this->googleAuthTokenCredentials instanceof FetchAuthTokenInterface) {
return $this->googleAuthTokenCredentials;
}

$serviceAccount = $this->getServiceAccount();

if ($serviceAccount !== null) {
return $this->googleAuthTokenCredentials = new ServiceAccountCredentials(self::API_CLIENT_SCOPES, $serviceAccount);
return $this->googleAuthTokenCredentials = new ServiceAccountCredentials(
self::API_CLIENT_SCOPES,
$this->normalizeServiceAccount($serviceAccount),
);
}

try {
Expand All @@ -759,4 +733,22 @@ private function getGoogleAuthTokenCredentials(): ?FetchAuthTokenInterface
return null;
}
}

private function mapServiceAccount(mixed $value): ServiceAccount
{
return $this->getMapper()
->allowSuperfluousKeys()
->snakeToCamelCase()
->map(ServiceAccount::class, Source::parse($value));
}

/**
* @return array<non-empty-string, mixed>
*/
private function normalizeServiceAccount(ServiceAccount $serviceAccount): array
{
return $this->getNormalizer()
->camelToSnakeCase()
->toArray($serviceAccount);
}
}
41 changes: 41 additions & 0 deletions src/Firebase/ServiceAccount.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
<?php

declare(strict_types=1);

namespace Kreait\Firebase;

use SensitiveParameter;

/**
* @internal
*/
final class ServiceAccount
{
public function __construct(
/** @var non-empty-string */
public string $type,
/** @var non-empty-string */
#[SensitiveParameter] public string $projectId,
/** @var non-empty-string */
#[SensitiveParameter] public string $clientEmail,
/** @var non-empty-string */
#[SensitiveParameter] public string $clientId,
/** @var non-empty-string */
#[SensitiveParameter] public string $privateKey,
/** @var non-empty-string */
#[SensitiveParameter] public string $privateKeyId,
/** @var non-empty-string */
#[SensitiveParameter] public string $authUri,
/** @var non-empty-string */
#[SensitiveParameter] public string $tokenUri,
/** @var non-empty-string */
#[SensitiveParameter] public string $authProviderX509CertUrl,
/** @var non-empty-string */
#[SensitiveParameter] public string $clientX509CertUrl,
/** @var non-empty-string|null */
#[SensitiveParameter] public ?string $quotaProjectId = null,
/** @var non-empty-string|null */
#[SensitiveParameter] public ?string $universeDomain = null,
) {
}
}
Loading