Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for Session Cookies #244

Closed
wants to merge 4 commits into from
Closed
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
18 changes: 13 additions & 5 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,20 +22,28 @@
"ext-mbstring": "*",
"ext-openssl": "*",
"giggsey/libphonenumber-for-php": "^8.9",
"google/auth": "^0.11.0|^1.0",
"guzzlehttp/guzzle": "^6.2.1",
"google/auth": "^1.4",
"google/cloud-storage": "^1.9",
"guzzlehttp/guzzle": "^6.3.3",
"kevinrob/guzzle-cache-middleware": "^3.2",
"kreait/firebase-tokens": "^1.7.2",
"kreait/gcp-metadata": "^1.0.1",
"lcobucci/jwt": "^3.2",
"mtdowling/jmespath.php": "^2.3",
"superbalist/flysystem-google-storage": "^7.0"
"league/flysystem": "^1.0",
"mtdowling/jmespath.php": "^2.4",
"psr/http-message": "^1.0",
"superbalist/flysystem-google-storage": "^7.1"
},
"require-dev": {
"friendsofphp/php-cs-fixer": "^2.0",
"phpstan/phpstan-phpunit": "^0.9.2",
"phpunit/phpunit": "^6.0|^7.0",
"phpunit/phpunit": "^6.0",
"symfony/var-dumper": "^3.3"
},
"suggest": {
"psr/cache": "Allows caching requests to the Firebase authentication APIs",
"psr/simple-cache": "Allows caching requests to the Firebase authentication APIs"
},
"autoload": {
"psr-4": {
"Kreait\\Firebase\\": "src/Firebase"
Expand Down
158 changes: 140 additions & 18 deletions src/Firebase/Auth.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,23 +3,29 @@
namespace Kreait\Firebase;

use Firebase\Auth\Token\Domain\Generator as TokenGenerator;
use Firebase\Auth\Token\Domain\Verifier as IdTokenVerifier;
use Firebase\Auth\Token\Domain\Verifier as LegacyIdTokenVerifier;
use Firebase\Auth\Token\Exception\InvalidSignature;
use Firebase\Auth\Token\Exception\InvalidToken;
use Firebase\Auth\Token\Exception\IssuedInTheFuture;
use Kreait\Firebase\Auth\ApiClient;
use Kreait\Firebase\Auth\IdTokenVerifier as NewIdTokenVerifier;
use Kreait\Firebase\Auth\SessionTokenVerifier;
use Kreait\Firebase\Auth\UserRecord;
use Kreait\Firebase\Exception\Auth\InvalidPassword;
use Kreait\Firebase\Exception\Auth\RevokedIdToken;
use Kreait\Firebase\Exception\Auth\UserNotFound;
use Kreait\Firebase\Exception\AuthException;
use Kreait\Firebase\Exception\InvalidArgumentException;
use Kreait\Firebase\Exception\RevokedToken;
use Kreait\Firebase\Util\DT;
use Kreait\Firebase\Util\Duration;
use Kreait\Firebase\Util\JSON;
use Kreait\Firebase\Value\ClearTextPassword;
use Kreait\Firebase\Value\Email;
use Kreait\Firebase\Value\PhoneNumber;
use Kreait\Firebase\Value\Provider;
use Kreait\Firebase\Value\Uid;
use Lcobucci\JWT\Parser;
use Lcobucci\JWT\Token;
use Psr\Http\Message\UriInterface;

Expand All @@ -36,15 +42,34 @@ class Auth
private $tokenGenerator;

/**
* @var IdTokenVerifier
* @var NewIdTokenVerifier|LegacyIdTokenVerifier
*/
private $idTokenVerifier;

public function __construct(ApiClient $client, TokenGenerator $customToken, IdTokenVerifier $idTokenVerifier)
/**
* @var SessionTokenVerifier
*/
private $sessionTokenVerifier;

/**
* @param ApiClient $client
* @param TokenGenerator $customToken
* @param NewIdTokenVerifier|LegacyIdTokenVerifier $idTokenVerifier
* @param SessionTokenVerifier $sessionTokenVerifier
*/
public function __construct(ApiClient $client, TokenGenerator $customToken, $idTokenVerifier, SessionTokenVerifier $sessionTokenVerifier)
{
$this->client = $client;
$this->tokenGenerator = $customToken;

if ($idTokenVerifier instanceof LegacyIdTokenVerifier) {
trigger_error(sprintf('%s is deprecated, please use %s instead', LegacyIdTokenVerifier::class, NewIdTokenVerifier::class), E_USER_DEPRECATED);
} elseif (!($idTokenVerifier instanceof NewIdTokenVerifier)) {
throw new InvalidArgumentException(sprintf('An ID token verifier must be an instance of %s', NewIdTokenVerifier::class));
}

$this->idTokenVerifier = $idTokenVerifier;
$this->sessionTokenVerifier = $sessionTokenVerifier;
}

public function getApiClient(): ApiClient
Expand Down Expand Up @@ -312,6 +337,7 @@ public function createCustomToken($uid, array $claims = null): Token
* @param bool $checkIfRevoked whether to check if the ID token is revoked
* @param bool $allowFutureTokens whether to allow tokens that have been issued for the future
*
* @throws InvalidArgumentException
* @throws InvalidToken
* @throws IssuedInTheFuture
* @throws RevokedIdToken
Expand All @@ -321,29 +347,45 @@ public function createCustomToken($uid, array $claims = null): Token
*/
public function verifyIdToken($idToken, bool $checkIfRevoked = null, bool $allowFutureTokens = null): Token
{
try {
$idToken = $idToken instanceof Token ? $idToken : (new Parser())->parse($idToken);
} catch (\Throwable $e) {
throw new InvalidArgumentException('The given value could not be parsed as a token: '.$e->getMessage());
}

$checkIfRevoked = $checkIfRevoked ?? false;
$allowFutureTokens = $allowFutureTokens ?? false;

try {
$verifiedToken = $this->idTokenVerifier->verifyIdToken($idToken);
} catch (IssuedInTheFuture $e) {
if (!$allowFutureTokens) {
throw $e;
}

$verifiedToken = $e->getToken();
}
if ($this->idTokenVerifier instanceof NewIdTokenVerifier) {
try {
$this->idTokenVerifier->verify($idToken);
} catch (Exception\InvalidToken $e) {

Choose a reason for hiding this comment

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

this catch InvalidToken hidden away exception message from inside verifier

for example, expired idToken will be thrown by verifyExpiry with message 'The token is expired since xxx'
with this too broad catch, will result in new InvalidToken without message from line 371

$issuedAt = $idToken->getClaim('iat', false);
$isIssuedInTheFuture = $issuedAt > time();

if ($checkIfRevoked) {
$tokenAuthenticatedAt = DT::toUTCDateTimeImmutable($verifiedToken->getClaim('auth_time'));
$validSince = $this->getUser($verifiedToken->getClaim('sub'))->tokensValidAfterTime;
if ($isIssuedInTheFuture && !$allowFutureTokens) {
throw new IssuedInTheFuture($idToken);
}

if ($validSince && ($tokenAuthenticatedAt < $validSince)) {
throw new RevokedIdToken($verifiedToken);
if (!$isIssuedInTheFuture) {
throw new InvalidToken($idToken);
}
}
} else {
try {
$this->idTokenVerifier->verify($idToken);
} catch (IssuedInTheFuture $e) {
if (!$allowFutureTokens) {
throw $e;
}
}
}

return $verifiedToken;
if ($checkIfRevoked && $this->tokenHasBeenRevoked($idToken)) {
throw new RevokedIdToken($idToken);
}

return $idToken;
}

/**
Expand Down Expand Up @@ -389,6 +431,17 @@ public function revokeRefreshTokens($uid)
$this->client->revokeRefreshTokens((string) $uid);
}

public function tokenHasBeenRevoked($token): bool
{
$token = $token instanceof Token ? $token : (new Parser())->parse($token);
$uid = new Uid($token->getClaim('sub'));

$validSince = $this->getUser($uid)->tokensValidAfterTime;
$tokenAuthenticatedAt = DT::toUTCDateTimeImmutable($token->getClaim('auth_time'));

return $tokenAuthenticatedAt < $validSince;
}

public function unlinkProvider($uid, $provider): UserRecord
{
$uid = $uid instanceof Uid ? $uid : new Uid($uid);
Expand All @@ -402,4 +455,73 @@ public function unlinkProvider($uid, $provider): UserRecord

return $this->getUser($uid);
}

/**
* Creates a session token for the user identified by the given ID Token and returns it.
*
* @see https://firebase.google.com/docs/auth/admin/manage-cookies#create_session_cookie
*
* @param Token|string $idToken
* @param Duration|null $lifetime
*
* @throws InvalidArgumentException
* @throws AuthException when the session token can not be created
*
* @return Token
*/
public function createSessionToken($idToken, $lifetime = null): Token
{
$idToken = $idToken instanceof Token ? $idToken : (new Parser())->parse($idToken);
$lifetime = $lifetime instanceof Duration ? $lifetime : Duration::fromValue($lifetime ?: '5 minutes');

if (!$lifetime->isWithin(Duration::fromValue('5 minutes'), Duration::fromValue('2 weeks'))) {
throw new InvalidArgumentException("A session cookie's lifetime must be between 5 minutes and 2 weeks.");
}

$response = $this->client->createSessionCookie((string) $idToken, $lifetime->inSeconds());

try {
$data = JSON::decode((string) $response->getBody(), true);
} catch (\InvalidArgumentException $e) {
throw new AuthException("Unable to parse the response from the Firebase API as JSON: {$e->getMessage()}", $e->getCode(), $e);
}

if (!($tokenString = $data['sessionCookie'] ?? null)) {
throw new AuthException("The Firebase API response does not include a 'sessionCookie' field, got: ".JSON::prettyPrint($data));
}

try {
return (new Parser())->parse($tokenString);
} catch (\Throwable $e) {
throw new AuthException("Unable to parse {$tokenString} into a JWT token: ".$e->getMessage(), $e->getCode(), $e);
}
}

/**
* Verifies a JWT session token.
*
* @param Token|string $token
* @param bool $checkIfRevoked If set to true, verifies if the session corresponding to the ID token was revoked.
* @param Duration|mixed $leeway
*
* @throws InvalidArgumentException
* @throws Exception\InvalidToken
* @throws RevokedToken
*
* @return void
*/
public function verifySessionToken($token, bool $checkIfRevoked = false, $leeway = null)
{
try {
$token = $token instanceof Token ? $token : (new Parser())->parse($token);
} catch (\Throwable $e) {
throw new InvalidArgumentException('The given value could not be parsed as a token: '.$e->getMessage());
}

$this->sessionTokenVerifier->verify($token, $leeway);

if ($checkIfRevoked && $this->tokenHasBeenRevoked($token)) {
throw RevokedToken::because('The session has been revoked');
}
}
}
8 changes: 8 additions & 0 deletions src/Firebase/Auth/ApiClient.php
Original file line number Diff line number Diff line change
Expand Up @@ -231,6 +231,14 @@ public function unlinkProvider(string $uid, array $providers): ResponseInterface
]);
}

public function createSessionCookie(string $idToken, int $lifetimeInSeconds): ResponseInterface
{
return $this->request('createSessionCookie', [
'idToken' => $idToken,
'validDuration' => $lifetimeInSeconds,
]);
}

private function request(string $uri, $data): ResponseInterface
{
if ($data instanceof \JsonSerializable && empty($data->jsonSerialize())) {
Expand Down
Loading