Skip to content

Commit b2efe14

Browse files
committed
Introduce type-safe mapping of service account credentials
1 parent 1c046ce commit b2efe14

6 files changed

Lines changed: 144 additions & 217 deletions

File tree

src/Firebase/Factory.php

Lines changed: 46 additions & 93 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@
77
use Beste\Cache\InMemoryCache;
88
use Beste\Clock\SystemClock;
99
use Beste\Clock\WrappingClock;
10-
use Beste\Json;
1110
use Firebase\JWT\CachedKeySet;
1211
use Google\Auth\ApplicationDefaultCredentials;
1312
use Google\Auth\Credentials\ServiceAccountCredentials;
@@ -48,21 +47,12 @@
4847
use Psr\Log\LogLevel;
4948
use Stringable;
5049
use Throwable;
51-
use UnexpectedValueException;
5250

5351
use function array_filter;
5452
use function is_string;
5553
use function sprintf;
5654
use function trim;
5755

58-
/**
59-
* @phpstan-type ServiceAccountShape array{
60-
* project_id: non-empty-string,
61-
* client_email: non-empty-string,
62-
* private_key: non-empty-string,
63-
* type: 'service_account'
64-
* }
65-
*/
6656
final class Factory
6757
{
6858
public const API_CLIENT_SCOPES = [
@@ -86,10 +76,7 @@ final class Factory
8676
*/
8777
private ?string $defaultStorageBucket = null;
8878

89-
/**
90-
* @var ServiceAccountShape|null
91-
*/
92-
private ?array $serviceAccount = null;
79+
private ?ServiceAccount $serviceAccount = null;
9380

9481
private ?FetchAuthTokenInterface $googleAuthTokenCredentials = null;
9582

@@ -155,40 +142,16 @@ public function __construct()
155142
}
156143

157144
/**
158-
* @param non-empty-string|ServiceAccountShape $value
145+
* @param string|array<mixed> $value
146+
*
147+
* @throws InvalidArgumentException
159148
*/
160149
public function withServiceAccount(string|array $value): self
161150
{
162-
if (is_string($value) && str_starts_with($value, '{')) {
163-
try {
164-
/** @var ServiceAccountShape $serviceAccount */
165-
$serviceAccount = Json::decode($value, true);
166-
} catch (UnexpectedValueException $e) {
167-
throw new InvalidArgumentException('Invalid service account: '.$e->getMessage(), $e->getCode(), $e);
168-
}
169-
170-
$factory = clone $this;
171-
$factory->serviceAccount = $serviceAccount;
172-
173-
return $factory;
174-
}
175-
176-
if (is_string($value)) {
177-
try {
178-
/** @var ServiceAccountShape $serviceAccount */
179-
$serviceAccount = Json::decodeFile($value, true);
180-
181-
$factory = clone $this;
182-
$factory->serviceAccount = $serviceAccount;
183-
184-
return $factory;
185-
} catch (UnexpectedValueException $e) {
186-
throw new InvalidArgumentException('Invalid service account: '.$e->getMessage(), $e->getCode(), $e);
187-
}
188-
}
151+
$serviceAccount = $this->mapServiceAccount($value);
189152

190153
$factory = clone $this;
191-
$factory->serviceAccount = $value;
154+
$factory->serviceAccount = $serviceAccount;
192155

193156
return $factory;
194157
}
@@ -414,8 +377,8 @@ public function createAppCheck(): Contract\AppCheck
414377
return new AppCheck(
415378
new AppCheck\ApiClient($http),
416379
new AppCheckTokenGenerator(
417-
$serviceAccount['client_email'],
418-
$serviceAccount['private_key'],
380+
$serviceAccount->clientEmail,
381+
$serviceAccount->privateKey,
419382
$this->clock,
420383
),
421384
new AppCheckTokenVerifier($projectId, $keySet),
@@ -548,16 +511,7 @@ public function createStorage(): Contract\Storage
548511
* @deprecated 7.20.0
549512
* @codeCoverageIgnore
550513
*
551-
* @return array{
552-
* credentialsType: string|null,
553-
* databaseUrl: string,
554-
* defaultStorageBucket: string|null,
555-
* serviceAccount: string|array<string, string>|null,
556-
* projectId: string,
557-
* tenantId: non-empty-string|null,
558-
* tokenCacheType: class-string,
559-
* verifierCacheType: class-string,
560-
* }
514+
* @return array<mixed>
561515
*/
562516
public function getDebugInfo(): array
563517
{
@@ -645,13 +599,7 @@ public function createApiClient(?array $config = null, ?array $middlewares = nul
645599
}
646600

647601
/**
648-
* @return array{
649-
* projectId: non-empty-string,
650-
* authCache: CacheItemPoolInterface,
651-
* credentialsFetcher?: FetchAuthTokenInterface,
652-
* keyFile?: ServiceAccountShape,
653-
* keyFilePath?: non-empty-string
654-
* }
602+
* @return array<non-empty-string, mixed>
655603
*/
656604
private function googleCloudClientConfig(): array
657605
{
@@ -667,7 +615,7 @@ private function googleCloudClientConfig(): array
667615

668616
$serviceAccount = $this->getServiceAccount();
669617
if ($serviceAccount !== null) {
670-
$config['keyFile'] = $serviceAccount;
618+
$config['keyFile'] = $this->normalizeServiceAccount($serviceAccount);
671619
}
672620

673621
return $config;
@@ -687,6 +635,8 @@ private function getProjectId(): string
687635
? $credentials->getProjectId()
688636
: Util::getenv('GOOGLE_CLOUD_PROJECT');
689637

638+
$projectId ??= $this->getServiceAccount()?->projectId;
639+
690640
if (is_string($projectId) && $projectId !== '') {
691641
return $this->projectId = $projectId;
692642
}
@@ -699,23 +649,15 @@ private function getProjectId(): string
699649
*/
700650
private function getDatabaseUrl(): string
701651
{
702-
if ($this->databaseUrl === null) {
703-
$this->databaseUrl = sprintf('https://%s.firebaseio.com', $this->getProjectId());
704-
}
705-
706-
return $this->databaseUrl;
652+
return $this->databaseUrl ??= sprintf('https://%s.firebaseio.com', $this->getProjectId());
707653
}
708654

709655
/**
710656
* @return non-empty-string
711657
*/
712658
private function getStorageBucketName(): string
713659
{
714-
if ($this->defaultStorageBucket === null) {
715-
$this->defaultStorageBucket = sprintf('%s.appspot.com', $this->getProjectId());
716-
}
717-
718-
return $this->defaultStorageBucket;
660+
return $this->defaultStorageBucket ??= sprintf('%s.appspot.com', $this->getProjectId());
719661
}
720662

721663
private function createCustomTokenGenerator(): ?CustomTokenViaGoogleCredentials
@@ -755,41 +697,34 @@ private function createSessionCookieVerifier(): SessionCookieVerifier
755697
return SessionCookieVerifier::createWithProjectIdAndCache($this->getProjectId(), $this->verifierCache ?? $this->defaultCache);
756698
}
757699

758-
/**
759-
* @return ServiceAccountShape|null
760-
*/
761-
private function getServiceAccount(): ?array
700+
private function getServiceAccount(): ?ServiceAccount
762701
{
763-
if ($this->serviceAccount === null) {
764-
$googleApplicationCredentials = Util::getenv('GOOGLE_APPLICATION_CREDENTIALS');
765-
766-
if ($googleApplicationCredentials === null) {
767-
return null;
768-
}
769-
770-
if (!str_starts_with($googleApplicationCredentials, '{')) {
771-
return null;
772-
}
702+
if ($this->serviceAccount !== null) {
703+
return $this->serviceAccount;
704+
}
773705

774-
/** @var ServiceAccountShape $serviceAccount */
775-
$serviceAccount = Json::decode($googleApplicationCredentials, true);
706+
$googleApplicationCredentials = Util::getenv('GOOGLE_APPLICATION_CREDENTIALS');
776707

777-
$this->serviceAccount = $serviceAccount;
708+
if ($googleApplicationCredentials === null) {
709+
return $this->serviceAccount;
778710
}
779711

780-
return $this->serviceAccount;
712+
return $this->serviceAccount = $this->mapServiceAccount($googleApplicationCredentials);
781713
}
782714

783715
private function getGoogleAuthTokenCredentials(): ?FetchAuthTokenInterface
784716
{
785-
if ($this->googleAuthTokenCredentials !== null) {
717+
if ($this->googleAuthTokenCredentials instanceof FetchAuthTokenInterface) {
786718
return $this->googleAuthTokenCredentials;
787719
}
788720

789721
$serviceAccount = $this->getServiceAccount();
790722

791723
if ($serviceAccount !== null) {
792-
return $this->googleAuthTokenCredentials = new ServiceAccountCredentials(self::API_CLIENT_SCOPES, $serviceAccount);
724+
return $this->googleAuthTokenCredentials = new ServiceAccountCredentials(
725+
self::API_CLIENT_SCOPES,
726+
$this->normalizeServiceAccount($serviceAccount),
727+
);
793728
}
794729

795730
try {
@@ -798,4 +733,22 @@ private function getGoogleAuthTokenCredentials(): ?FetchAuthTokenInterface
798733
return null;
799734
}
800735
}
736+
737+
private function mapServiceAccount(mixed $value): ServiceAccount
738+
{
739+
return $this->getMapper()
740+
->allowSuperfluousKeys()
741+
->snakeToCamelCase()
742+
->map(ServiceAccount::class, Source::parse($value));
743+
}
744+
745+
/**
746+
* @return array<non-empty-string, mixed>
747+
*/
748+
private function normalizeServiceAccount(ServiceAccount $serviceAccount): array
749+
{
750+
return $this->getNormalizer()
751+
->camelToSnakeCase()
752+
->toArray($serviceAccount);
753+
}
801754
}

src/Firebase/ServiceAccount.php

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Kreait\Firebase;
6+
7+
use SensitiveParameter;
8+
9+
/**
10+
* @internal
11+
*/
12+
final class ServiceAccount
13+
{
14+
public function __construct(
15+
/** @var non-empty-string */
16+
public string $type,
17+
/** @var non-empty-string */
18+
#[SensitiveParameter] public string $projectId,
19+
/** @var non-empty-string */
20+
#[SensitiveParameter] public string $clientEmail,
21+
/** @var non-empty-string */
22+
#[SensitiveParameter] public string $clientId,
23+
/** @var non-empty-string */
24+
#[SensitiveParameter] public string $privateKey,
25+
/** @var non-empty-string */
26+
#[SensitiveParameter] public string $privateKeyId,
27+
/** @var non-empty-string */
28+
#[SensitiveParameter] public string $authUri,
29+
/** @var non-empty-string */
30+
#[SensitiveParameter] public string $tokenUri,
31+
/** @var non-empty-string */
32+
#[SensitiveParameter] public string $authProviderX509CertUrl,
33+
/** @var non-empty-string */
34+
#[SensitiveParameter] public string $clientX509CertUrl,
35+
/** @var non-empty-string|null */
36+
#[SensitiveParameter] public ?string $quotaProjectId = null,
37+
/** @var non-empty-string|null */
38+
#[SensitiveParameter] public ?string $universeDomain = null,
39+
) {
40+
}
41+
}

tests/Integration/Auth/CustomTokenViaGoogleCredentialsTest.php

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,11 @@
88
use Kreait\Firebase\Auth\CustomTokenViaGoogleCredentials;
99
use Kreait\Firebase\Contract\Auth;
1010
use Kreait\Firebase\Factory;
11+
use Kreait\Firebase\ServiceAccount;
1112
use Kreait\Firebase\Tests\IntegrationTestCase;
13+
use Kreait\Firebase\Valinor\Mapper;
14+
use Kreait\Firebase\Valinor\Normalizer;
15+
use Kreait\Firebase\Valinor\Source;
1216
use Lcobucci\JWT\Encoding\JoseEncoder;
1317
use Lcobucci\JWT\Token\Parser;
1418
use Lcobucci\JWT\UnencryptedToken;
@@ -27,7 +31,14 @@ final class CustomTokenViaGoogleCredentialsTest extends IntegrationTestCase
2731

2832
protected function setUp(): void
2933
{
30-
$credentials = new ServiceAccountCredentials(Factory::API_CLIENT_SCOPES, self::$serviceAccount);
34+
$serviceAccount = (new Mapper())
35+
->allowSuperfluousKeys()
36+
->snakeToCamelCase()
37+
->map(ServiceAccount::class, Source::parse(self::$credentials));
38+
39+
$normalizedServiceAccount = (new Normalizer())->camelToSnakeCase()->toArray($serviceAccount);
40+
41+
$credentials = new ServiceAccountCredentials(Factory::API_CLIENT_SCOPES, $normalizedServiceAccount);
3142

3243
$this->generator = new CustomTokenViaGoogleCredentials($credentials);
3344
$this->auth = self::$factory->createAuth();

0 commit comments

Comments
 (0)