238 lines
8.1 KiB
PHP
238 lines
8.1 KiB
PHP
<?php
|
||
defined('BASEPATH') or exit('No direct script access allowed');
|
||
|
||
/**
|
||
* Firebase Auth helper – verify Firebase ID tokens (JWT) for API auth.
|
||
* Complies with Firebase Auth process: clients can send "Authorization: Bearer <id_token>"
|
||
* and the backend verifies the token with Google's public keys.
|
||
* Uses FCM_PROJECT_ID (or FIREBASE_PROJECT_ID) for aud/iss validation.
|
||
*/
|
||
|
||
if (!function_exists('firebase_auth_get_bearer_token')) {
|
||
/**
|
||
* Get Bearer token from Authorization header.
|
||
*
|
||
* @return string|null Token or null if missing/invalid
|
||
*/
|
||
function firebase_auth_get_bearer_token()
|
||
{
|
||
$auth = isset($_SERVER['HTTP_AUTHORIZATION']) ? $_SERVER['HTTP_AUTHORIZATION'] : '';
|
||
if ($auth === '' && function_exists('apache_request_headers')) {
|
||
$headers = apache_request_headers();
|
||
$auth = isset($headers['Authorization']) ? $headers['Authorization'] : (isset($headers['authorization']) ? $headers['authorization'] : '');
|
||
}
|
||
if (preg_match('/^\s*Bearer\s+(\S+)\s*$/i', $auth, $m)) {
|
||
return $m[1];
|
||
}
|
||
return null;
|
||
}
|
||
}
|
||
|
||
if (!function_exists('firebase_auth_base64url_decode')) {
|
||
function firebase_auth_base64url_decode($data)
|
||
{
|
||
$pad = 4 - (strlen($data) % 4);
|
||
if ($pad !== 4) {
|
||
$data .= str_repeat('=', $pad);
|
||
}
|
||
return base64_decode(strtr($data, '-_', '+/'));
|
||
}
|
||
}
|
||
|
||
if (!function_exists('firebase_auth_fetch_google_jwks')) {
|
||
/**
|
||
* Fetch and cache Firebase Auth public keys (JWK set).
|
||
*
|
||
* @return array|null JWK set array or null on failure
|
||
*/
|
||
function firebase_auth_fetch_google_jwks()
|
||
{
|
||
$cache_key = 'firebase_auth_jwks';
|
||
$cache_ttl = 3600;
|
||
if (function_exists('get_instance')) {
|
||
$ci = &get_instance();
|
||
if (isset($ci->cache) && method_exists($ci->cache, 'get')) {
|
||
$cached = $ci->cache->get($cache_key);
|
||
if ($cached !== false) {
|
||
return $cached;
|
||
}
|
||
}
|
||
}
|
||
$url = 'https://www.googleapis.com/service_accounts/v1/jwk/securetoken@system.gserviceaccount.com';
|
||
$ctx = stream_context_create(array('http' => array('timeout' => 10)));
|
||
$raw = @file_get_contents($url, false, $ctx);
|
||
if ($raw === false) {
|
||
log_message('error', 'Firebase Auth: Failed to fetch JWKs');
|
||
return null;
|
||
}
|
||
$jwks = json_decode($raw, true);
|
||
if (empty($jwks['keys'])) {
|
||
log_message('error', 'Firebase Auth: Invalid JWKs response');
|
||
return null;
|
||
}
|
||
if (function_exists('get_instance')) {
|
||
$ci = &get_instance();
|
||
if (isset($ci->cache) && method_exists($ci->cache, 'save')) {
|
||
$ci->cache->save($cache_key, $jwks, $cache_ttl);
|
||
}
|
||
}
|
||
return $jwks;
|
||
}
|
||
}
|
||
|
||
if (!function_exists('firebase_auth_jwk_to_pem')) {
|
||
/**
|
||
* Convert JWK (RSA n,e) to PEM for openssl.
|
||
*
|
||
* @param array $jwk JWK with kty RSA, n, e
|
||
* @return string|false PEM or false
|
||
*/
|
||
function firebase_auth_jwk_to_pem($jwk)
|
||
{
|
||
if (empty($jwk['n']) || empty($jwk['e']) || (isset($jwk['kty']) && $jwk['kty'] !== 'RSA')) {
|
||
return false;
|
||
}
|
||
$n = firebase_auth_base64url_decode($jwk['n']);
|
||
$e = firebase_auth_base64url_decode($jwk['e']);
|
||
if ($n === false || $e === false) {
|
||
return false;
|
||
}
|
||
$rsa = array(
|
||
'n' => $n,
|
||
'e' => $e,
|
||
);
|
||
$der = firebase_auth_build_rsa_der($rsa);
|
||
if ($der === false) {
|
||
return false;
|
||
}
|
||
$pem = "-----BEGIN PUBLIC KEY-----\n" . chunk_split(base64_encode($der), 64, "\n") . "-----END PUBLIC KEY-----";
|
||
return $pem;
|
||
}
|
||
}
|
||
|
||
if (!function_exists('firebase_auth_build_rsa_der')) {
|
||
function firebase_auth_build_rsa_der($rsa)
|
||
{
|
||
$n = $rsa['n'];
|
||
$e = $rsa['e'];
|
||
$n_len = strlen($n);
|
||
$e_len = strlen($e);
|
||
if ($n[0] & "\x80") {
|
||
$n_len++;
|
||
}
|
||
if ($e[0] & "\x80") {
|
||
$e_len++;
|
||
}
|
||
$seq = "\x02" . chr($e_len) . ($e[0] & "\x80" ? "\0" : '') . $e
|
||
. "\x02" . chr($n_len) . ($n[0] & "\x80" ? "\0" : '') . $n;
|
||
$bit_string = "\x03" . chr(strlen($seq) + 1) . "\x00" . $seq;
|
||
$oid = "\x06\x09\x2a\x86\x48\x86\xf7\x0d\x01\x01\x01\x05\x00";
|
||
$inner = "\x30" . firebase_auth_der_len(strlen($bit_string) + strlen($oid)) . $bit_string . $oid;
|
||
$outer = "\x30" . firebase_auth_der_len(strlen($inner)) . $inner;
|
||
return $outer;
|
||
}
|
||
}
|
||
|
||
if (!function_exists('firebase_auth_der_len')) {
|
||
function firebase_auth_der_len($len)
|
||
{
|
||
if ($len < 128) {
|
||
return chr($len);
|
||
}
|
||
$buf = '';
|
||
while ($len > 0) {
|
||
$buf = chr($len & 0xff) . $buf;
|
||
$len >>= 8;
|
||
}
|
||
return chr(0x80 | strlen($buf)) . $buf;
|
||
}
|
||
}
|
||
|
||
if (!function_exists('firebase_auth_verify_id_token')) {
|
||
/**
|
||
* Verify a Firebase ID token (JWT) and return decoded payload.
|
||
*
|
||
* @param string $id_token The JWT string
|
||
* @param string|null $project_id Firebase project ID (default: FCM_PROJECT_ID or FIREBASE_PROJECT_ID constant)
|
||
* @return array|null Payload (uid, email, etc.) or null if invalid
|
||
*/
|
||
function firebase_auth_verify_id_token($id_token, $project_id = null)
|
||
{
|
||
if (!is_string($id_token) || $id_token === '') {
|
||
return null;
|
||
}
|
||
$parts = explode('.', $id_token);
|
||
if (count($parts) !== 3) {
|
||
return null;
|
||
}
|
||
$header = json_decode(firebase_auth_base64url_decode($parts[0]), true);
|
||
$payload = json_decode(firebase_auth_base64url_decode($parts[1]), true);
|
||
if (!$header || !$payload) {
|
||
log_message('debug', 'Firebase Auth: Invalid JWT decode');
|
||
return null;
|
||
}
|
||
if ($project_id === null || $project_id === '') {
|
||
if (defined('FCM_PROJECT_ID') && FCM_PROJECT_ID !== '') {
|
||
$project_id = FCM_PROJECT_ID;
|
||
} elseif (defined('FIREBASE_PROJECT_ID') && FIREBASE_PROJECT_ID !== '') {
|
||
$project_id = FIREBASE_PROJECT_ID;
|
||
} else {
|
||
$project_id = '';
|
||
}
|
||
}
|
||
if ($project_id === '') {
|
||
log_message('error', 'Firebase Auth: FCM_PROJECT_ID not set');
|
||
return null;
|
||
}
|
||
$expected_iss = 'https://securetoken.google.com/' . $project_id;
|
||
if (empty($payload['iss']) || $payload['iss'] !== $expected_iss) {
|
||
log_message('debug', 'Firebase Auth: iss mismatch');
|
||
return null;
|
||
}
|
||
if (empty($payload['aud']) || $payload['aud'] !== $project_id) {
|
||
log_message('debug', 'Firebase Auth: aud mismatch');
|
||
return null;
|
||
}
|
||
if (empty($payload['exp']) || (int) $payload['exp'] < time()) {
|
||
log_message('debug', 'Firebase Auth: token expired');
|
||
return null;
|
||
}
|
||
$jwks = firebase_auth_fetch_google_jwks();
|
||
if (!$jwks) {
|
||
return null;
|
||
}
|
||
$kid = isset($header['kid']) ? $header['kid'] : null;
|
||
$key = null;
|
||
foreach ($jwks['keys'] as $k) {
|
||
if (isset($k['kid']) && $k['kid'] === $kid) {
|
||
$key = $k;
|
||
break;
|
||
}
|
||
}
|
||
if (!$key) {
|
||
$key = $jwks['keys'][0];
|
||
}
|
||
$pem = firebase_auth_jwk_to_pem($key);
|
||
if (!$pem) {
|
||
return null;
|
||
}
|
||
$pub = openssl_pkey_get_public($pem);
|
||
if ($pub === false) {
|
||
return null;
|
||
}
|
||
$payload_to_verify = $parts[0] . '.' . $parts[1];
|
||
$sig_raw = firebase_auth_base64url_decode($parts[2]);
|
||
if ($sig_raw === false) {
|
||
openssl_free_key($pub);
|
||
return null;
|
||
}
|
||
$ok = openssl_verify($payload_to_verify, $sig_raw, $pub, OPENSSL_ALGO_SHA256);
|
||
openssl_free_key($pub);
|
||
if ($ok !== 1) {
|
||
log_message('debug', 'Firebase Auth: signature verification failed');
|
||
return null;
|
||
}
|
||
return $payload;
|
||
}
|
||
}
|