Merge pull request #2978 from HadleySo/dev

Third Party Authentication (SSO/OIDC/Header Authentication)
This commit is contained in:
Fabian Berg
2026-03-20 09:17:13 +01:00
committed by GitHub
22 changed files with 2820 additions and 38 deletions

5
.gitignore vendored
View File

@@ -1,9 +1,11 @@
/application/config/database.php
/application/config/config.php
/application/config/database.php
/application/config/sso.php
/application/config/redis.php
/application/config/memcached.php
/application/config/**/config.php
/application/config/**/database.php
/application/config/**/sso.php
/application/config/**/redis.php
/application/config/**/memcached.php
/application/logs/*
@@ -33,3 +35,4 @@ docker-compose.yml
.demo
.htaccess
.user.ini
.env

View File

@@ -109,6 +109,19 @@ $config['auth_mode'] = '3';
$config['auth_level'][3] = 'Operator';
$config['auth_level'][99] = 'Administrator';
/*
|--------------------------------------------------------------------------
| Third-Party Authentication (SSO)
|--------------------------------------------------------------------------
|
| Enable SSO support via a trusted HTTP header containing a JWT access token.
| When enabled, a sso.php config file is required (see sso.sample.php).
|
| Documentation: https://docs.wavelog.org/admin-guide/configuration/thirdparty-authentication/
*/
$config['auth_header_enable'] = false;
/*
|--------------------------------------------------------------------------
| Base Site URL
@@ -904,7 +917,6 @@ $config['enable_dxcluster_file_cache_worked'] = false;
*/
$config['dxcluster_refresh_time'] = 30;
/*
|--------------------------------------------------------------------------
| Internal tools

View File

@@ -22,7 +22,7 @@ $config['migration_enabled'] = TRUE;
|
*/
$config['migration_version'] = 272;
$config['migration_version'] = 273;
/*
|--------------------------------------------------------------------------

View File

@@ -0,0 +1,214 @@
<?php defined('BASEPATH') or exit('No direct script access allowed');
/**
*--------------------------------------------------------------------------
* SSO / Header Authentication Settings
*--------------------------------------------------------------------------
*
* Copy this file to sso.php and adjust the values to your environment.
* This file is loaded automatically when auth_header_enable = true in config.php.
*
* Documentation: https://docs.wavelog.org/admin-guide/configuration/thirdparty-authentication/
*/
/**
* --------------------------------------------------------------------------
* Debug Mode
* --------------------------------------------------------------------------
*
* When enabled, the decoded JWT token will be logged on every login attempt.
* This is useful for troubleshooting claim mapping and token forwarding issues.
* Requires 'log_threshold' to be set to 2 (debug) or higher in config.php.
*
* DON'T FORGET TO DISABLE THIS IN PRODUCTION ENVIRONMENTS, AS JWT TOKENS MAY CONTAIN SENSITIVE INFORMATION!
*/
$config['auth_header_debug_jwt'] = false;
/**
*--------------------------------------------------------------------------
* Automatic User Creation
*--------------------------------------------------------------------------
*
* When enabled, Wavelog will automatically create a local user account the
* first time a valid JWT token is received for an unknown user. The account
* is created with a random password.
*
* Disable this if you want to provision users manually before they can log in.
* It's recommended to keep this enabled in most cases and do access control
* management through your identity provider.
*
* Recommendation: true
*/
$config['auth_header_create'] = true;
/**
*--------------------------------------------------------------------------
* Allow Direct Login
*--------------------------------------------------------------------------
*
* When SSO is enabled, the standard Wavelog login form can be hidden. Default
* behavior is to show the login form alongside the SSO button, allowing users to choose.
* Set this to false to hide the login form and force users to authenticate exclusively through SSO.
*
* Recommendation: depends on your needs. You can disable direct login for a more seamless SSO experience, but enable
* it if you want to allow admins or users without SSO access to log in through the standard form.
*/
$config['auth_header_allow_direct_login'] = false;
/**
* --------------------------------------------------------------------------
* Hide Password Field in User Profile
* --------------------------------------------------------------------------
*
* When enabled, the password field in the user profile edit page is hidden for users authenticated through SSO.
* While there are legit use cases for allowing users to set a local password (e.g. as a backup login method),
* hiding the password field can help avoid confusion and reinforce the idea that the account is managed through the identity provider.
*/
$config['auth_header_hide_password_field'] = true;
/**
* --------------------------------------------------------------------------
* Locked Data Badge
* --------------------------------------------------------------------------
*
* HTML snippet for a badge indicating that a field is locked and managed through the Identity Provider. This is shown next to fields in the user profile that are mapped to JWT claims and not allowed to be changed manually.
* You can customize the appearance and tooltip text as needed. Leave empty to use the default.
*/
$config['auth_header_locked_data_badge'] = "";
$config['auth_header_locked_data_tip'] = "";
/**
*--------------------------------------------------------------------------
* Access Token Header
*--------------------------------------------------------------------------
*
* The name of the HTTP header that contains the JWT access token forwarded
* by your reverse proxy or identity provider (e.g. OAuth2 Proxy, mod_auth_oidc).
*
* Note: PHP converts HTTP headers to uppercase and replaces hyphens with
* underscores, prefixed with HTTP_. For example, the header
* "X-Forwarded-Access-Token" becomes "HTTP_X_FORWARDED_ACCESS_TOKEN".
*/
$config['auth_header_accesstoken'] = "HTTP_X_FORWARDED_ACCESS_TOKEN";
/**
*--------------------------------------------------------------------------
* SSO Login Button Text
*--------------------------------------------------------------------------
*
* The label shown on the SSO login button on the Wavelog login page.
*/
$config['auth_header_text'] = "Login with SSO";
/**
*--------------------------------------------------------------------------
* Logout URL
*--------------------------------------------------------------------------
*
* URL to redirect the user to after logging out of Wavelog. Leave empty
* to redirect to the standard Wavelog login page while keeping the SSO session.
* This is default since logging out of Wavelog does not necessarily mean logging out of the identity provider.
*
* When using OAuth2 Proxy in front of Keycloak, the URL must first hit the
* OAuth2 Proxy sign-out endpoint, which then redirects to the Keycloak
* end-session endpoint. The whitelist_domains of OAuth2 Proxy must include
* the Keycloak domain.
*
* Example (OAuth2 Proxy + Keycloak):
* $config['auth_header_url_logout'] = 'https://log.example.org/oauth2/sign_out'
* . '?rd=https://auth.example.org/realms/example/protocol/openid-connect/logout';
*
* Recommendation: Keep it empty
*/
$config['auth_header_url_logout'] = "";
/**
*--------------------------------------------------------------------------
* JWKS URI (Signature Verification)
*--------------------------------------------------------------------------
*
* URL of the JWKS endpoint of your identity provider. Wavelog uses this to
* fetch the public keys and cryptographically verify the JWT signature on
* every login. This is strongly recommended in production.
*
* Leave empty to skip signature verification (legacy / trusted-proxy mode).
* Only disable verification if your reverse proxy fully manages authentication
* and you fully trust the forwarded token without additional validation.
*
* Example (Keycloak):
* $config['auth_header_jwks_uri'] = 'https://auth.example.org/realms/example/protocol/openid-connect/certs';
*
* Recommendation: Set this to the JWKS endpoint of your identity provider for enhanced security.
*/
$config['auth_header_jwks_uri'] = "";
/**
*--------------------------------------------------------------------------
* JWT Claim Mapping
*--------------------------------------------------------------------------
*
* Maps Wavelog database fields to JWT claim names. Each key is a column
* name in the users table. The value is an array with the following options:
*
* 'claim' => The JWT claim name to read the value from.
*
* 'override_on_update' => If true, the field is updated from the JWT on
* every login. If false, the value is only written
* once when the account is created.
* Set to false for fields the user should be able
* to change independently (e.g. username).
*
* 'allow_manual_change'=> If true, the user can edit this field in their
* Wavelog profile. If false, the field is locked
* and managed exclusively by the identity provider.
*
* You can add any additional column from the users table here. Fields not
* listed will use their default values on account creation and will not be
* touched on subsequent logins.
*
* The following fields are required for account creation and must be present:
* - user_name
* - user_email
* - user_callsign
*/
$config['auth_headers_claim_config'] = [
'user_name' => [
'claim' => 'preferred_username',
'override_on_update' => true,
'allow_manual_change' => false
],
'user_email' => [
'claim' => 'email',
'override_on_update' => true,
'allow_manual_change' => false
],
'user_callsign' => [
'claim' => 'callsign',
'override_on_update' => true,
'allow_manual_change' => false
],
'user_locator' => [
'claim' => 'locator',
'override_on_update' => true,
'allow_manual_change' => false
],
'user_firstname' => [
'claim' => 'given_name',
'override_on_update' => true,
'allow_manual_change' => false
],
'user_lastname' => [
'claim' => 'family_name',
'override_on_update' => true,
'allow_manual_change' => false
],
];

View File

@@ -0,0 +1,367 @@
<?php if (!defined('BASEPATH')) exit('No direct script access allowed');
use Firebase\JWT\JWT;
use Firebase\JWT\JWK;
require_once APPPATH . '../src/jwt/src/JWT.php';
require_once APPPATH . '../src/jwt/src/Key.php';
require_once APPPATH . '../src/jwt/src/JWK.php';
/*
Handles header based authentication
*/
class Header_auth extends CI_Controller {
public function __construct() {
parent::__construct();
if ($this->config->item('auth_header_enable')) {
$this->config->load('sso', true, true);
} else {
$this->_sso_error(__("SSO Authentication is disabled."));
}
}
/**
* Authenticate using a JWT from a trusted request header. This endpoint is meant to be called by a reverse proxy that sits in front of Wavelog and handles the actual authentication (e.g. OAuth2 Proxy, Apache mod_auth_oidc, etc.).
* The reverse proxy validates the user's session and forwards a JWT access token containing the user's identity and claims in a trusted HTTP header. This method decodes the token, verifies it, extracts the user information
* and logs the user in. Depending on configuration, it can also automatically create a local user account if one does not exist, and update existing user data.
*
* For more information check out the documentation: https://docs.wavelog.org/admin-guide/configuration/thirdparty-authentication/
*/
public function login() {
// Guard: feature must be enabled
if (!$this->config->item('auth_header_enable')) {
$this->_sso_error(__("SSO Authentication is disabled. Check your configuration."));
}
// Decode JWT access token forwarded by idp
$accesstoken_path = $this->config->item('auth_header_accesstoken', 'sso') ?? false;
if (!$accesstoken_path) {
log_message('error', 'SSO Authentication: Access Token Path not configured in config.php.');
$this->_sso_error();
}
$token = $this->input->server($accesstoken_path, true);
if (empty($token)) {
log_message('error', 'SSO Authentication: Missing access token header.');
$this->_sso_error();
}
if ($this->config->item('auth_header_debug_jwt', 'sso')) {
log_message('debug', 'Raw JWT: ' . $token);
}
$claims = $this->_verify_jwt($token);
if (empty($claims)) {
$this->_sso_error("SSO Authentication failed. Invalid token.");
}
if ($this->config->item('auth_header_debug_jwt', 'sso')) {
log_message('debug', 'Decoded and validated JWT: ' . json_encode($claims, JSON_PRETTY_PRINT));
}
$claim_map = $this->config->item('auth_headers_claim_config', 'sso');
// Extract all mapped claims dynamically — supports custom fields added by the admin
$mapped = [];
foreach ($claim_map as $db_field => $cfg) {
$mapped[$db_field] = $claims[$cfg['claim']] ?? null;
}
// Build composite key: JSON {iss, sub} — uniquely identifies a user across IdP and user
$iss = $claims['iss'] ?? '';
$sub = $claims['sub'] ?? '';
if (empty($iss) || empty($sub)) {
log_message('error', 'SSO Authentication: Missing iss or sub claim in access token.');
$this->_sso_error();
}
$external_identifier = json_encode(['iss' => $iss, 'sub' => $sub]);
$this->load->model('user_model');
$query = $this->user_model->get_by_external_account($external_identifier);
if (!$query || $query->num_rows() !== 1) {
if ($this->config->item('auth_header_create', 'sso')) {
$this->_create_user($mapped, $external_identifier);
$query = $this->user_model->get_by_external_account($external_identifier);
} else {
$this->_sso_error(__("User not found."));
return;
}
}
if (!$query || $query->num_rows() !== 1) {
log_message('error', 'SSO Authentication: Something went terribly wrong. Check error log.');
$this->_sso_error();
return;
}
$user = $query->row();
// Prevent clubstation direct login via header (mirrors User::login)
if (!empty($user->clubstation) && $user->clubstation == 1) {
$this->_sso_error(__("You can't login to a clubstation directly. Use your personal account instead."));
}
// Maintenance mode check (admin only allowed)
if (ENVIRONMENT === 'maintenance' && (int)$user->user_type !== 99) {
$this->_sso_error(__("Sorry. This instance is currently in maintenance mode. Only administrators are currently allowed to log in."));
}
// Check if club station before update
// Don't update fields in maintenance mode
if (ENVIRONMENT !== 'maintenance') {
// Update fields from JWT claims where override_on_update is enabled
$this->_update_user_from_claims($user->user_id, $mapped);
}
// Establish session
$this->user_model->update_session($user->user_id);
$this->user_model->set_last_seen($user->user_id);
// Set language cookie (mirrors User::login)
$cookie = [
'name' => $this->config->item('gettext_cookie', 'gettext'),
'value' => $user->user_language,
'expire' => 1000,
'secure' => $this->config->item('cookie_secure'),
];
$this->input->set_cookie($cookie);
log_message('info', "User ID [{$user->user_id}] logged in via SSO.");
redirect('dashboard');
}
/**
* Decode and verify a JWT token. Returns the claims array on success, null on failure.
*
* JWKS mode (auth_header_jwks_uri configured):
* Firebase JWT::decode() handles signature, exp, nbf and alg validation.
*
* Low Security mode (no JWKS URI):
* Payload is decoded without signature verification. exp, nbf and alg
* are checked manually.
*
* In both modes: iat age and typ are validated after decoding.
*
* @param string $token
*
* @return array|null
*/
private function _verify_jwt(string $token): ?array {
$jwksUri = $this->config->item('auth_header_jwks_uri', 'sso');
if (!empty($jwksUri)) {
try {
$jwksJson = file_get_contents($jwksUri);
if ($jwksJson === false) {
log_message('error', 'SSO Authentication: Failed to fetch JWKS from ' . $jwksUri);
return null;
}
$jwks = json_decode($jwksJson, true);
$keys = JWK::parseKeySet($jwks);
$claims = (array) JWT::decode($token, $keys);
} catch (\Exception $e) {
log_message('error', 'SSO Authentication: JWT decode/verify failed: ' . $e->getMessage());
return null;
}
} else {
// Low Security mode: decode without signature verification to provide a minimal level of security
log_message('debug', 'SSO Authentication: JWKS URI not configured, skipping signature verification.');
$parts = explode('.', $token);
if (count($parts) !== 3) {
log_message('error', 'SSO Authentication: JWT does not have 3 parts.');
return null;
}
$b64decode = function (string $part): ?array {
$json = base64_decode(str_pad(strtr($part, '-_', '+/'), strlen($part) % 4, '=', STR_PAD_RIGHT));
if ($json === false) return null;
$data = json_decode($json, true);
return is_array($data) ? $data : null;
};
$header = $b64decode($parts[0]);
$claims = $b64decode($parts[1]);
if ($claims === null) {
log_message('error', 'SSO Authentication: Failed to decode JWT payload.');
return null;
}
if (($claims['exp'] ?? 0) < time()) {
log_message('error', 'SSO Authentication: JWT Token is expired.');
return null;
}
if (isset($claims['nbf']) && $claims['nbf'] > time()) {
log_message('error', 'SSO Authentication: JWT Token is not valid yet.');
return null;
}
$alg = $header['alg'] ?? 'none';
if ($alg == "none") {
log_message('error', 'SSO Authentication: Algorithm "' . $alg . '" is not allowed.');
return null;
}
}
// Common checks (both modes)
if (isset($claims['iat']) && $claims['iat'] < (time() - 86400)) {
log_message('error', 'SSO Authentication: Token is older than 24 hours.');
return null;
}
if (isset($claims['typ']) && $claims['typ'] !== 'Bearer') {
log_message('error', 'SSO Authentication: JWT Token is no Bearer Token.');
return null;
}
return $claims;
}
/**
* Update user fields from JWT claims where override_on_update is enabled.
*
* @param int $user_id
* @param array $claim_map
* @param array $values Associative array of field => value from the JWT
*
* @return void
*/
private function _update_user_from_claims(int $user_id, array $mapped): void {
$updates = [];
$claim_map = $this->config->item('auth_headers_claim_config', 'sso');
foreach ($claim_map as $db_field => $cfg) {
if (!empty($cfg['override_on_update']) && $mapped[$db_field] !== null) {
$updates[$db_field] = $mapped[$db_field];
}
}
if (!empty($updates)) {
$this->user_model->update_sso_claims($user_id, $updates);
}
}
/**
* Helper to create a user if it does not exist.
*
* @param array $mapped All DB field => value pairs extracted from JWT claims
* @param string $external_identifier Composite key JSON {iss, sub} — stored once, never updated
*
* @return void
*/
private function _create_user(array $mapped, string $external_identifier) {
if (empty($mapped['user_email']) || empty($mapped['user_callsign'])) {
log_message('error', 'SSO Authentication: Missing email or callsign claim in access token.');
$this->_sso_error();
}
if (empty($mapped['user_name'])) {
log_message('error', 'SSO Authentication: Missing username claim in access token.');
$this->_sso_error();
}
// $club_id = $this->config->item('auth_header_club_id', 'sso') ?: ''; // TODO: Add support to add a user to a clubstation
$this->load->model('user_model');
$result = $this->user_model->add(
$mapped['user_name'] ?? '',
bin2hex(random_bytes(64)), // password is always random
$mapped['user_email'] ?? '',
3, // user_type: never admin via SSO
$mapped['user_firstname'] ?? '',
$mapped['user_lastname'] ?? '',
$mapped['user_callsign'] ?? '',
$mapped['user_locator'] ?? '',
$mapped['user_timezone'] ?? 24,
$mapped['user_measurement_base'] ?? 'M',
$mapped['dashboard_map'] ?? 'Y',
$mapped['user_date_format'] ?? 'Y-m-d',
$mapped['user_stylesheet'] ?? 'darkly',
$mapped['user_qth_lookup'] ?? '0',
$mapped['user_sota_lookup'] ?? '0',
$mapped['user_wwff_lookup'] ?? '0',
$mapped['user_pota_lookup'] ?? '0',
$mapped['user_show_notes'] ?? 1,
$mapped['user_column1'] ?? 'Mode',
$mapped['user_column2'] ?? 'RSTS',
$mapped['user_column3'] ?? 'RSTR',
$mapped['user_column4'] ?? 'Band',
$mapped['user_column5'] ?? 'Country',
$mapped['user_show_profile_image'] ?? '0',
$mapped['user_previous_qsl_type'] ?? '0',
$mapped['user_amsat_status_upload'] ?? '0',
$mapped['user_mastodon_url'] ?? '',
$mapped['user_default_band'] ?? 'ALL',
$mapped['user_default_confirmation'] ?? 'QL',
$mapped['user_qso_end_times'] ?? '0',
$mapped['user_qso_db_search_priority'] ?? 'Y',
$mapped['user_quicklog'] ?? '0',
$mapped['user_quicklog_enter'] ?? '0',
$mapped['user_language'] ?? 'english',
$mapped['user_hamsat_key'] ?? '',
$mapped['user_hamsat_workable_only'] ?? '',
$mapped['user_iota_to_qso_tab'] ?? '',
$mapped['user_sota_to_qso_tab'] ?? '',
$mapped['user_wwff_to_qso_tab'] ?? '',
$mapped['user_pota_to_qso_tab'] ?? '',
$mapped['user_sig_to_qso_tab'] ?? '',
$mapped['user_dok_to_qso_tab'] ?? '',
$mapped['user_station_to_qso_tab'] ?? 0,
$mapped['user_lotw_name'] ?? '',
$mapped['user_lotw_password'] ?? '',
$mapped['user_eqsl_name'] ?? '',
$mapped['user_eqsl_password'] ?? '',
$mapped['user_clublog_name'] ?? '',
$mapped['user_clublog_password'] ?? '',
$mapped['user_winkey'] ?? '0',
$mapped['on_air_widget_enabled'] ?? '',
$mapped['on_air_widget_display_last_seen'] ?? '',
$mapped['on_air_widget_show_only_most_recent_radio'] ?? '',
$mapped['qso_widget_display_qso_time'] ?? '',
$mapped['dashboard_banner'] ?? '',
$mapped['dashboard_solar'] ?? '',
$mapped['global_oqrs_text'] ?? '',
$mapped['oqrs_grouped_search'] ?? '',
$mapped['oqrs_grouped_search_show_station_name'] ?? '',
$mapped['oqrs_auto_matching'] ?? '',
$mapped['oqrs_direct_auto_matching'] ?? '',
$mapped['user_dxwaterfall_enable'] ?? '',
$mapped['user_qso_show_map'] ?? '',
0, // clubstation
$external_identifier, // external_account
);
switch ($result) {
case EUSERNAMEEXISTS:
log_message('error', 'SSO Authentication: The SSO Integration tried to create a new User because the Username was not found. But the Username already exists. This should not happen as the user should be looked up by the same username before. Check your user provisioning and claims mapping configuration. Otherwise create an issue on https://github.com/wavelog/wavelog');
$this->_sso_error(__("Something went terribly wrong. Check the error log."));
break;
case EEMAILEXISTS:
log_message('error', 'SSO Authentication: The SSO Integration tried to create a new User because the Username was not found. But the E-mail for the new User already exists for an existing user. Check for existing Wavelog users with the same e-mail address as the one provided by your IdP.');
$this->_sso_error(__("Something went terribly wrong. Check the error log."));
break;
case OK:
return;
}
}
/**
* Helper to set flashdata and redirect to login with an error message. We use this a lot in the SSO login process, so we need a helper for this.
*
* @param string|null $message
*
* @return void
*/
private function _sso_error($message = null) {
if ($message === null) {
$message = __("SSO Config Error. Check error log.");
}
$this->session->set_flashdata('error', $message);
redirect('user/login');
die;
}
}

View File

@@ -2,6 +2,8 @@
class User extends CI_Controller {
private $pwd_placeholder = '**********';
public function index()
{
$this->load->model('user_model');
@@ -78,6 +80,11 @@ class User extends CI_Controller {
if ($this->user_model->exists_by_id($data['user_id']) && $modal != '') {
$user = $this->user_model->get_by_id($data['user_id'])->row();
$gettext = new Gettext;
$data['auth_header_enable'] = $this->config->item('auth_header_enable') ?? false;
if ($data['auth_header_enable']) {
$this->config->load('sso', true, true);
}
$data['user_name'] = $user->user_name;
$data['user_callsign'] = $user->user_callsign;
@@ -90,6 +97,7 @@ class User extends CI_Controller {
$data['last_seen'] = $user->last_seen;
$data['custom_date_format'] = $custom_date_format;
$data['has_flossie'] = ($this->config->item('encryption_key') == 'flossie1234555541') ? true : false;
$data['auth_header_allow_direct_login'] = $this->config->item('auth_header_allow_direct_login', 'sso') ?? true;
$this->load->view('user/modals/'.$modal.'_modal', $data);
} else {
@@ -169,6 +177,14 @@ class User extends CI_Controller {
$data['clubstation'] = ($this->input->get('club') ?? '') == '1' ? true : false;
$data['external_account'] = NULL;
$data['auth_header_enable'] = $this->config->item('auth_header_enable') ?? false;
$data['auth_header_allow_direct_login'] = true;
$data['auth_header_hide_password_field'] = false;
$data['auth_header_locked_data_badge'] = "Not Visible In Add UI";
$data['auth_header_locked_data_tip'] = "You should not see this message";
$data['sso_claim_config'] = [];
// Get themes list
$data['themes'] = $this->user_model->getThemes();
@@ -396,7 +412,6 @@ class User extends CI_Controller {
$query = $this->user_model->get_by_id($this->uri->segment(3));
$data['existing_languages'] = $this->config->item('languages');
$pwd_placeholder = '**********';
$this->load->model('bands');
$this->load->library('form_validation');
@@ -440,6 +455,19 @@ class User extends CI_Controller {
// Max value to be present in the "QSO page last QSO count" selectbox
$data['qso_page_last_qso_count_limit'] = QSO_PAGE_QSOS_COUNT_LIMIT;
// SSO / OIDC cases
$data['external_account'] = !empty($query->row()->external_account);
$data['auth_header_enable'] = $this->config->item('auth_header_enable') ?? false;
if ($data['auth_header_enable']) {
// expecting sso.php in the config folder
$this->config->load('sso', true, true);
}
$data['auth_header_allow_direct_login'] = $this->config->item('auth_header_allow_direct_login', 'sso') ?? true;
$data['auth_header_hide_password_field'] = $this->config->item('auth_header_hide_password_field', 'sso') ?? false;
$data['auth_header_locked_data_badge'] = $this->config->item('auth_header_locked_data_badge', 'sso') ?: 'IdP';
$data['auth_header_locked_data_tip'] = $this->config->item('auth_header_locked_data_tip', 'sso') ?: __("Can't be changed. Manage this through your Identity Provider.");
$data['sso_claim_config'] = $this->config->item('auth_headers_claim_config', 'sso') ?: [];
$data['page_title'] = __("Edit User");
if ($this->form_validation->run() == FALSE)
@@ -465,7 +493,7 @@ class User extends CI_Controller {
$data['user_password'] = $this->input->post('user_password',true);
} else {
if ($q->user_password !== '' && $q->user_password !== null) {
$data['user_password'] = $pwd_placeholder;
$data['user_password'] = $this->pwd_placeholder;
} else {
$data['user_password'] = '';
}
@@ -535,7 +563,7 @@ class User extends CI_Controller {
$data['user_clublog_password'] = $this->input->post('user_clublog_password', true);
} else {
if ($q->user_clublog_password !== '' && $q->user_clublog_password !== null) {
$data['user_clublog_password'] = $pwd_placeholder;
$data['user_clublog_password'] = $this->pwd_placeholder;
} else {
$data['user_clublog_password'] = '';
}
@@ -545,7 +573,7 @@ class User extends CI_Controller {
$data['user_lotw_password'] = $this->input->post('user_lotw_password', true);
} else {
if ($q->user_lotw_password !== '' && $q->user_lotw_password !== null) {
$data['user_lotw_password'] = $pwd_placeholder;
$data['user_lotw_password'] = $this->pwd_placeholder;
} else {
$data['user_lotw_password'] = '';
}
@@ -561,7 +589,7 @@ class User extends CI_Controller {
$data['user_eqsl_password'] = $this->input->post('user_eqsl_password', true);
} else {
if ($q->user_eqsl_password !== '' && $q->user_eqsl_password !== null) {
$data['user_eqsl_password'] = $pwd_placeholder;
$data['user_eqsl_password'] = $this->pwd_placeholder;
} else {
$data['user_eqsl_password'] = '';
}
@@ -953,7 +981,17 @@ class User extends CI_Controller {
}
unset($data);
switch($this->user_model->edit($this->input->post())) {
// SSO / OIDC: Override submitted values for fields managed by the IdP
$post_data = $this->input->post();
if (!empty($query->row()->external_account)) {
$post_data['user_name'] = $query->row()->user_name;
if (!($this->config->item('auth_header_allow_direct_login', 'sso') ?? true)) {
$post_data['user_password'] = $this->pwd_placeholder; // placeholder → model skips password update
}
}
switch($this->user_model->edit($post_data)) {
// Check for errors
case EUSERNAMEEXISTS:
$data['username_error'] = 'Username <b>'.$this->input->post('user_name', true).'</b> already in use!';
@@ -1243,6 +1281,15 @@ class User extends CI_Controller {
if ($this->form_validation->run() == FALSE) {
$data['page_title'] = __("Login");
$data['https_check'] = $this->https_check();
$data['auth_header_enable'] = $this->config->item('auth_header_enable') ?? false;
if ($data['auth_header_enable']) {
// expecting sso.php in the config folder
$this->config->load('sso', true, true);
}
$data['auth_header_text'] = $this->config->item('auth_header_text', 'sso') ?: '';
$data['hide_login_form'] = ($data['auth_header_enable'] && !($this->config->item('auth_header_allow_direct_login', 'sso') ?? true));
$this->load->view('interface_assets/mini_header', $data);
$this->load->view('user/login');
$this->load->view('interface_assets/footer');
@@ -1317,6 +1364,14 @@ class User extends CI_Controller {
$this->input->set_cookie('tmp_msg', json_encode(['notice', sprintf(__("User %s logged out."), $user_name)]), 10, '');
}
if ($this->config->item('auth_header_enable')) {
$this->config->load('sso', true, true);
$logout = $this->config->item('auth_header_url_logout', 'sso') ?: null;
if ($logout !== null) {
redirect($logout);
}
}
redirect('user/login');
}
@@ -1489,7 +1544,14 @@ class User extends CI_Controller {
$check_email = $this->user_model->check_email_address($data->user_email);
if($check_email == TRUE) {
// Is local login allowed
$auth_header_enable = $this->config->item('auth_header_enable') ?? false;
if ($auth_header_enable) {
$this->config->load('sso', true, true);
}
$auth_header_allow_direct_login = $this->config->item('auth_header_allow_direct_login', 'sso') ?? true;
if($check_email == TRUE && $auth_header_allow_direct_login) {
// Generate password reset code 50 characters long
$this->load->helper('string');
$reset_code = random_string('alnum', 50);

View File

@@ -0,0 +1,21 @@
<?php
defined('BASEPATH') or exit('No direct script access allowed');
class Migration_external_account extends CI_Migration {
public function up() {
$this->dbtry("ALTER TABLE users ADD COLUMN external_account JSON DEFAULT NULL AFTER clubstation");
}
public function down() {
$this->dbtry("ALTER TABLE users DROP COLUMN external_account");
}
function dbtry($what) {
try {
$this->db->query($what);
} catch (Exception $e) {
log_message("error", "Something gone wrong while altering users table. Executing: " . $this->db->last_query());
}
}
}

View File

@@ -213,7 +213,9 @@ class User_Model extends CI_Model {
// FUNCTION: bool add($username, $password, $email, $type)
// Add a user
// !!!!!!!!!!!!!!!!
// !! IMPORTANT NOTICE: Please inform DJ7NT and/or DF2ET when adding/removing/changing parameters here.
// !! IMPORTANT NOTICE: Please inform DJ7NT and/or DF2ET when adding/removing/changing parameters here.
// !! Also make sure you modify Header_auth::_create_user accordingly, otherwise SSO user creation will break.
// !! Also modify User_model::update_sso_claims with attributes that can be modified by IdP
// !!!!!!!!!!!!!!!!
function add($username, $password, $email, $type, $firstname, $lastname, $callsign, $locator, $timezone,
$measurement, $dashboard_map, $user_date_format, $user_stylesheet, $user_qth_lookup, $user_sota_lookup, $user_wwff_lookup,
@@ -225,7 +227,7 @@ class User_Model extends CI_Model {
$user_lotw_name, $user_lotw_password, $user_eqsl_name, $user_eqsl_password, $user_clublog_name, $user_clublog_password,
$user_winkey, $on_air_widget_enabled, $on_air_widget_display_last_seen, $on_air_widget_show_only_most_recent_radio,
$qso_widget_display_qso_time, $dashboard_banner, $dashboard_solar, $global_oqrs_text, $oqrs_grouped_search,
$oqrs_grouped_search_show_station_name, $oqrs_auto_matching, $oqrs_direct_auto_matching,$user_dxwaterfall_enable, $user_qso_show_map, $clubstation = 0) {
$oqrs_grouped_search_show_station_name, $oqrs_auto_matching, $oqrs_direct_auto_matching,$user_dxwaterfall_enable, $user_qso_show_map, $clubstation = 0, $external_account = null) {
// Check that the user isn't already used
if(!$this->exists($username)) {
$data = array(
@@ -269,6 +271,7 @@ class User_Model extends CI_Model {
'user_clublog_password' => xss_clean($user_clublog_password),
'winkey' => xss_clean($user_winkey),
'clubstation' => $clubstation,
'external_account' => $external_account
);
// Check the password is valid
@@ -440,7 +443,7 @@ class User_Model extends CI_Model {
$pwd_placeholder = '**********';
// Hash password
if($fields['user_password'] != NULL)
if(array_key_exists('user_password', $fields) && ($fields['user_password'] != NULL))
{
if (!file_exists('.demo') || (file_exists('.demo') && $this->session->userdata('user_type') == 99)) {
@@ -538,6 +541,11 @@ class User_Model extends CI_Model {
// This is really just a wrapper around User_Model::authenticate
function login() {
if (($this->config->item('auth_header_enable') ?? false) && !($this->config->item('auth_header_allow_direct_login') ?? true)) {
$this->session->set_flashdata('error', 'Direct login is disabled. Please use the SSO option to log in.');
redirect('user/login');
}
$username = $this->input->post('user_name', true);
$password = htmlspecialchars_decode($this->input->post('user_password', true));
@@ -746,6 +754,68 @@ class User_Model extends CI_Model {
return 0;
}
// FUNCTION: retrieve a user by their SSO composite key {iss, sub} stored as JSON
function get_by_external_account(string $key) {
$table = $this->config->item('auth_table');
$decoded = json_decode($key, true);
return $this->db->query(
"SELECT * FROM `$table` WHERE JSON_VALUE(external_account, '$.iss') = ? AND JSON_VALUE(external_account, '$.sub') = ?",
[$decoded['iss'], $decoded['sub']]
);
}
// FUNCTION: update specific user fields from SSO claims (bypass privilege check, used during login flow)
function update_sso_claims(int $user_id, array $fields): void {
// Only modify the following
$allowed = [
'user_name',
'user_email',
'user_callsign',
'user_locator',
'user_firstname',
'user_lastname',
'user_timezone',
'user_lotw_name',
'user_lotw_password',
'user_eqsl_name',
'user_eqsl_password',
'user_eqsl_qth_nickname',
'active_station_logbook',
'user_language',
'user_clublog_name',
'user_clublog_password',
'user_clublog_callsign',
'user_measurement_base',
'user_date_format',
'user_stylesheet',
'user_sota_lookup',
'user_wwff_lookup',
'user_pota_lookup',
'user_qth_lookup',
'user_show_notes',
'user_column1',
'user_column2',
'user_column3',
'user_column4',
'user_column5',
'user_show_profile_image',
'user_previous_qsl_type',
'user_amsat_status_upload',
'user_mastodon_url',
'user_default_band',
'user_default_confirmation',
'user_quicklog_enter',
'user_quicklog',
'user_qso_end_times',
'winkey',
'slug'
];
$fields = array_intersect_key($fields, array_flip($allowed));
$this->db->where('user_id', $user_id);
$this->db->update('users', $fields);
}
// FUNCTION: set's the last-login timestamp in user table
function set_last_seen($user_id) {
$data = array(

View File

@@ -53,18 +53,41 @@
<div class="card">
<div class="card-header"><?= __("Account"); ?></div>
<div class="card-body">
<?php
// Returns true if the field is managed by the IdP and the user cannot change it
$idp_locked = function($field) use ($external_account, $sso_claim_config) {
return $external_account && isset($sso_claim_config[$field]) && empty($sso_claim_config[$field]['allow_manual_change']);
};
$idp_badge = '<span class="badge bg-secondary ms-1" data-bs-toggle="tooltip" title="' . $auth_header_locked_data_tip . '"><i class="fa fa-lock"></i> ' . $auth_header_locked_data_badge . '</span>';
?>
<div class="mb-3">
<label><?= __("Username"); ?></label>
<input class="form-control" type="text" name="user_name" value="<?php if(isset($user_name)) { echo $user_name; } ?>" <?php if (isset($user_name) && $user_name == 'demo' && file_exists('.demo') && $this->session->userdata('user_type') !== '99') { echo 'disabled'; } ?> />
<?php if(isset($username_error)) { echo "<small class=\"badge bg-danger\">".$username_error."</small>"; } ?>
<label>
<?= __("Username"); ?>
<?php if ($idp_locked('user_name')) { echo $idp_badge; } ?>
</label>
<?php if ($idp_locked('user_name')) { ?>
<input class="form-control-plaintext fw-bold" name="user_name" value="<?= htmlspecialchars($user_name ?? '') ?>" readonly/>
<?php } else { ?>
<input class="form-control" type="text" name="user_name" value="<?php if(isset($user_name)) { echo $user_name; } ?>" <?php if (isset($user_name) && $user_name == 'demo' && file_exists('.demo') && $this->session->userdata('user_type') !== '99') { echo 'disabled'; } ?> />
<?php if(isset($username_error)) { echo "<small class=\"badge bg-danger\">".$username_error."</small>"; } ?>
<?php } ?>
</div>
<div class="mb-3">
<label><?= __("Email Address"); ?></label>
<input class="form-control" type="text" name="user_email" value="<?php if(isset($user_email)) { echo $user_email; } ?>" />
<?php if(isset($email_error)) { echo "<small class=\"badge bg-danger\">".$email_error."</small>"; } ?>
<label>
<?= __("Email Address"); ?>
<?php if ($idp_locked('user_email')) { echo $idp_badge; } ?>
</label>
<?php if ($idp_locked('user_email')) { ?>
<input class="form-control-plaintext fw-bold" name="user_email" value="<?= htmlspecialchars($user_email ?? '') ?>" readonly/>
<?php } else { ?>
<input class="form-control" type="text" name="user_email" value="<?php if(isset($user_email)) { echo $user_email; } ?>" />
<?php if(isset($email_error)) { echo "<small class=\"badge bg-danger\">".$email_error."</small>"; } ?>
<?php } ?>
</div>
<?php if (!$auth_header_enable || ($auth_header_allow_direct_login && (!$external_account || !$auth_header_hide_password_field))){ ?>
<div class="mb-3">
<label><?= __("Password"); ?></label>
<div class="input-group">
@@ -79,6 +102,7 @@
} else if (!isset($user_add)) { ?>
<?php } ?>
</div>
<?php } ?>
<hr/>
<div class="mb-3">
@@ -98,7 +122,7 @@
</select>
<?php } else {
$l = $this->config->item('auth_level');
echo $l[$user_type];
echo "<br><b>" . $l[$user_type] . "</b>";
}?>
<?php if ($clubstation) { ?>
<input type="hidden" name="clubstation" value="1" />
@@ -114,17 +138,29 @@
<div class="card-header"><?php if ($clubstation) { echo __("Callsign Owner"); } else { echo __("Personal");} ?></div>
<div class="card-body">
<div class="mb-3">
<label><?= __("First Name"); ?></label>
<input class="form-control" type="text" name="user_firstname" value="<?php if(isset($user_firstname)) { echo $user_firstname; } ?>" />
<?php if(isset($firstname_error)) { echo "<small class=\"badge bg-danger\">".$firstname_error."</small>"; } else { ?>
<?php } ?>
<label>
<?= __("First Name"); ?>
<?php if ($idp_locked('user_firstname')) { echo $idp_badge; } ?>
</label>
<?php if ($idp_locked('user_firstname')) { ?>
<input class="form-control-plaintext fw-bold" name="user_firstname" value="<?= htmlspecialchars($user_firstname ?? '') ?>" readonly/>
<?php } else { ?>
<input class="form-control" type="text" name="user_firstname" value="<?php if(isset($user_firstname)) { echo $user_firstname; } ?>" />
<?php if(isset($firstname_error)) { echo "<small class=\"badge bg-danger\">".$firstname_error."</small>"; } ?>
<?php } ?>
</div>
<div class="mb-3">
<label><?= __("Last Name"); ?></label>
<input class="form-control" type="text" name="user_lastname" value="<?php if(isset($user_lastname)) { echo $user_lastname; } ?>" />
<?php if(isset($lastname_error)) { echo "<small class=\"badge bg-danger\">".$lastname_error."</small>"; } else { ?>
<?php } ?>
<label>
<?= __("Last Name"); ?>
<?php if ($idp_locked('user_lastname')) { echo $idp_badge; } ?>
</label>
<?php if ($idp_locked('user_lastname')) { ?>
<input class="form-control-plaintext fw-bold" name="user_lastname" value="<?= htmlspecialchars($user_lastname ?? '') ?>" readonly/>
<?php } else { ?>
<input class="form-control" type="text" name="user_lastname" value="<?php if(isset($user_lastname)) { echo $user_lastname; } ?>" />
<?php if(isset($lastname_error)) { echo "<small class=\"badge bg-danger\">".$lastname_error."</small>"; } ?>
<?php } ?>
</div>
</div>
</div>
@@ -135,17 +171,29 @@
<div class="card-header"><?= __("Ham Radio"); ?></div>
<div class="card-body">
<div class="mb-3">
<label><?php if ($clubstation) { echo __("Special/Club Callsign"); } else { echo __("Callsign"); } ?></label>
<input class="form-control uppercase" type="text" name="user_callsign" pattern="^\S+$" value="<?php if(isset($user_callsign)) { echo $user_callsign; } ?>" />
<?php if(isset($callsign_error)) { echo "<small class=\"badge bg-danger\">".$callsign_error."</small>"; } else { ?>
<?php } ?>
<label>
<?php if ($clubstation) { echo __("Special/Club Callsign"); } else { echo __("Callsign"); } ?>
<?php if ($idp_locked('user_callsign')) { echo $idp_badge; } ?>
</label>
<?php if ($idp_locked('user_callsign')) { ?>
<input class="form-control-plaintext fw-bold uppercase" name="user_callsign" value="<?= htmlspecialchars($user_callsign ?? '') ?>" readonly/>
<?php } else { ?>
<input class="form-control uppercase" type="text" name="user_callsign" pattern="^\S+$" value="<?php if(isset($user_callsign)) { echo $user_callsign; } ?>" />
<?php if(isset($callsign_error)) { echo "<small class=\"badge bg-danger\">".$callsign_error."</small>"; } ?>
<?php } ?>
</div>
<div class="mb-3">
<label><?= __("Gridsquare"); ?></label>
<input class="form-control uppercase" type="text" name="user_locator" value="<?php if(isset($user_locator)) { echo $user_locator; } ?>" />
<?php if(isset($locator_error)) { echo "<small class=\"badge bg-danger\">".$locator_error."</small>"; } else { ?>
<?php } ?>
<label>
<?= __("Gridsquare"); ?>
<?php if ($idp_locked('user_locator')) { echo $idp_badge; } ?>
</label>
<?php if ($idp_locked('user_locator')) { ?>
<input class="form-control-plaintext fw-bold uppercase" name="user_locator" value="<?= htmlspecialchars($user_locator ?? '') ?>" readonly/>
<?php } else { ?>
<input class="form-control uppercase" type="text" name="user_locator" value="<?php if(isset($user_locator)) { echo $user_locator; } ?>" />
<?php if(isset($locator_error)) { echo "<small class=\"badge bg-danger\">".$locator_error."</small>"; } ?>
<?php } ?>
</div>
</div>
</div>

View File

@@ -64,6 +64,7 @@
?>
<form method="post" action="<?php echo site_url('user/login'); ?>" name="users">
<?php $this->form_validation->set_error_delimiters('', ''); ?>
<?php if (!$hide_login_form) { ?>
<input type="hidden" name="id" value="<?php echo $this->uri->segment(3); ?>" />
<div class="mb-2">
<label for="floatingInput"><strong><?= __("Username"); ?></strong></label>
@@ -87,8 +88,17 @@
</div>
</div>
</div>
<button class="w-100 btn btn-primary mb-2" type="submit"><?= __("Login"); ?> →</button>
<?php } ?>
<?php // only show if header auth enabled
if ($auth_header_enable == true) { ?>
<div class="mb-2">
<a href="<?php echo site_url('header_auth/login'); ?>" class="btn btn-secondary w-100">
<?= $auth_header_text; ?>
</a>
</div>
<?php } ?>
<?php $this->load->view('layout/messages'); ?>
<button class="w-100 btn btn-primary" type="submit"><?= __("Login"); ?> →</button>
</form>
</div>
</div>

View File

@@ -34,7 +34,7 @@
</button>
<?php } ?>
<?php if (!$is_clubstation) { ?>
<?php if (!$is_clubstation && $auth_header_allow_direct_login) { ?>
<button class="btn btn-primary mb-2" data-bs-target="#passwordResetModal" data-bs-toggle="modal">
<i class="fas fa-key"></i> <?= __("Send a Password Reset Link via Email"); ?>
</button>

View File

@@ -109,6 +109,19 @@ $config['auth_mode'] = '3';
$config['auth_level'][3] = 'Operator';
$config['auth_level'][99] = 'Administrator';
/*
|--------------------------------------------------------------------------
| Third-Party Authentication (SSO)
|--------------------------------------------------------------------------
|
| Enable SSO support via a trusted HTTP header containing a JWT access token.
| When enabled, a sso.php config file is required (see sso.sample.php).
|
| Documentation: https://docs.wavelog.org/admin-guide/configuration/thirdparty-authentication/
*/
$config['auth_header_enable'] = false;
/*
|--------------------------------------------------------------------------
| Base Site URL

30
src/jwt/LICENSE Normal file
View File

@@ -0,0 +1,30 @@
Copyright (c) 2011, Neuman Vong
All rights reserved.
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are met:
* Redistributions of source code must retain the above copyright
notice, this list of conditions and the following disclaimer.
* Redistributions in binary form must reproduce the above
copyright notice, this list of conditions and the following
disclaimer in the documentation and/or other materials provided
with the distribution.
* Neither the name of the copyright holder nor the names of other
contributors may be used to endorse or promote products derived
from this software without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.

426
src/jwt/README.md Normal file
View File

@@ -0,0 +1,426 @@
####
SOURCE https://github.com/firebase/php-jwt
VERSION 7.0.3
IMPORTED 18th March 2026, HB9HIL
####
PHP-JWT
=======
A simple library to encode and decode JSON Web Tokens (JWT) in PHP, conforming to [RFC 7519](https://tools.ietf.org/html/rfc7519).
Installation
------------
Use composer to manage your dependencies and download PHP-JWT:
```bash
composer require firebase/php-jwt
```
Optionally, install the `paragonie/sodium_compat` package from composer if your
php env does not have libsodium installed:
```bash
composer require paragonie/sodium_compat
```
Example
-------
```php
use Firebase\JWT\JWT;
use Firebase\JWT\Key;
$key = 'example_key';
$payload = [
'iss' => 'http://example.org',
'aud' => 'http://example.com',
'iat' => 1356999524,
'nbf' => 1357000000
];
/**
* IMPORTANT:
* You must specify supported algorithms for your application. See
* https://tools.ietf.org/html/draft-ietf-jose-json-web-algorithms-40
* for a list of spec-compliant algorithms.
*/
$jwt = JWT::encode($payload, $key, 'HS256');
$decoded = JWT::decode($jwt, new Key($key, 'HS256'));
print_r($decoded);
// Pass a stdClass in as the third parameter to get the decoded header values
$headers = new stdClass();
$decoded = JWT::decode($jwt, new Key($key, 'HS256'), $headers);
print_r($headers);
/*
NOTE: This will now be an object instead of an associative array. To get
an associative array, you will need to cast it as such:
*/
$decoded_array = (array) $decoded;
/**
* You can add a leeway to account for when there is a clock skew times between
* the signing and verifying servers. It is recommended that this leeway should
* not be bigger than a few minutes.
*
* Source: http://self-issued.info/docs/draft-ietf-oauth-json-web-token.html#nbfDef
*/
JWT::$leeway = 60; // $leeway in seconds
$decoded = JWT::decode($jwt, new Key($key, 'HS256'));
```
Example encode/decode headers
-------
Decoding the JWT headers without verifying the JWT first is NOT recommended, and is not supported by
this library. This is because without verifying the JWT, the header values could have been tampered with.
Any value pulled from an unverified header should be treated as if it could be any string sent in from an
attacker. If this is something you still want to do in your application for whatever reason, it's possible to
decode the header values manually simply by calling `json_decode` and `base64_decode` on the JWT
header part:
```php
use Firebase\JWT\JWT;
$key = 'example_key';
$payload = [
'iss' => 'http://example.org',
'aud' => 'http://example.com',
'iat' => 1356999524,
'nbf' => 1357000000
];
$headers = [
'x-forwarded-for' => 'www.google.com'
];
// Encode headers in the JWT string
$jwt = JWT::encode($payload, $key, 'HS256', null, $headers);
// Decode headers from the JWT string WITHOUT validation
// **IMPORTANT**: This operation is vulnerable to attacks, as the JWT has not yet been verified.
// These headers could be any value sent by an attacker.
list($headersB64, $payloadB64, $sig) = explode('.', $jwt);
$decoded = json_decode(base64_decode($headersB64), true);
print_r($decoded);
```
Example with RS256 (openssl)
----------------------------
```php
use Firebase\JWT\JWT;
use Firebase\JWT\Key;
$privateKey = <<<EOD
-----BEGIN RSA PRIVATE KEY-----
MIIEowIBAAKCAQEAuzWHNM5f+amCjQztc5QTfJfzCC5J4nuW+L/aOxZ4f8J3Frew
M2c/dufrnmedsApb0By7WhaHlcqCh/ScAPyJhzkPYLae7bTVro3hok0zDITR8F6S
JGL42JAEUk+ILkPI+DONM0+3vzk6Kvfe548tu4czCuqU8BGVOlnp6IqBHhAswNMM
78pos/2z0CjPM4tbeXqSTTbNkXRboxjU29vSopcT51koWOgiTf3C7nJUoMWZHZI5
HqnIhPAG9yv8HAgNk6CMk2CadVHDo4IxjxTzTTqo1SCSH2pooJl9O8at6kkRYsrZ
WwsKlOFE2LUce7ObnXsYihStBUDoeBQlGG/BwQIDAQABAoIBAFtGaOqNKGwggn9k
6yzr6GhZ6Wt2rh1Xpq8XUz514UBhPxD7dFRLpbzCrLVpzY80LbmVGJ9+1pJozyWc
VKeCeUdNwbqkr240Oe7GTFmGjDoxU+5/HX/SJYPpC8JZ9oqgEA87iz+WQX9hVoP2
oF6EB4ckDvXmk8FMwVZW2l2/kd5mrEVbDaXKxhvUDf52iVD+sGIlTif7mBgR99/b
c3qiCnxCMmfYUnT2eh7Vv2LhCR/G9S6C3R4lA71rEyiU3KgsGfg0d82/XWXbegJW
h3QbWNtQLxTuIvLq5aAryV3PfaHlPgdgK0ft6ocU2de2FagFka3nfVEyC7IUsNTK
bq6nhAECgYEA7d/0DPOIaItl/8BWKyCuAHMss47j0wlGbBSHdJIiS55akMvnAG0M
39y22Qqfzh1at9kBFeYeFIIU82ZLF3xOcE3z6pJZ4Dyvx4BYdXH77odo9uVK9s1l
3T3BlMcqd1hvZLMS7dviyH79jZo4CXSHiKzc7pQ2YfK5eKxKqONeXuECgYEAyXlG
vonaus/YTb1IBei9HwaccnQ/1HRn6MvfDjb7JJDIBhNClGPt6xRlzBbSZ73c2QEC
6Fu9h36K/HZ2qcLd2bXiNyhIV7b6tVKk+0Psoj0dL9EbhsD1OsmE1nTPyAc9XZbb
OPYxy+dpBCUA8/1U9+uiFoCa7mIbWcSQ+39gHuECgYAz82pQfct30aH4JiBrkNqP
nJfRq05UY70uk5k1u0ikLTRoVS/hJu/d4E1Kv4hBMqYCavFSwAwnvHUo51lVCr/y
xQOVYlsgnwBg2MX4+GjmIkqpSVCC8D7j/73MaWb746OIYZervQ8dbKahi2HbpsiG
8AHcVSA/agxZr38qvWV54QKBgCD5TlDE8x18AuTGQ9FjxAAd7uD0kbXNz2vUYg9L
hFL5tyL3aAAtUrUUw4xhd9IuysRhW/53dU+FsG2dXdJu6CxHjlyEpUJl2iZu/j15
YnMzGWHIEX8+eWRDsw/+Ujtko/B7TinGcWPz3cYl4EAOiCeDUyXnqnO1btCEUU44
DJ1BAoGBAJuPD27ErTSVtId90+M4zFPNibFP50KprVdc8CR37BE7r8vuGgNYXmnI
RLnGP9p3pVgFCktORuYS2J/6t84I3+A17nEoB4xvhTLeAinAW/uTQOUmNicOP4Ek
2MsLL2kHgL8bLTmvXV4FX+PXphrDKg1XxzOYn0otuoqdAQrkK4og
-----END RSA PRIVATE KEY-----
EOD;
$publicKey = <<<EOD
-----BEGIN PUBLIC KEY-----
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAuzWHNM5f+amCjQztc5QT
fJfzCC5J4nuW+L/aOxZ4f8J3FrewM2c/dufrnmedsApb0By7WhaHlcqCh/ScAPyJ
hzkPYLae7bTVro3hok0zDITR8F6SJGL42JAEUk+ILkPI+DONM0+3vzk6Kvfe548t
u4czCuqU8BGVOlnp6IqBHhAswNMM78pos/2z0CjPM4tbeXqSTTbNkXRboxjU29vS
opcT51koWOgiTf3C7nJUoMWZHZI5HqnIhPAG9yv8HAgNk6CMk2CadVHDo4IxjxTz
TTqo1SCSH2pooJl9O8at6kkRYsrZWwsKlOFE2LUce7ObnXsYihStBUDoeBQlGG/B
wQIDAQAB
-----END PUBLIC KEY-----
EOD;
$payload = [
'iss' => 'example.org',
'aud' => 'example.com',
'iat' => 1356999524,
'nbf' => 1357000000
];
$jwt = JWT::encode($payload, $privateKey, 'RS256');
echo "Encode:\n" . print_r($jwt, true) . "\n";
$decoded = JWT::decode($jwt, new Key($publicKey, 'RS256'));
/*
NOTE: This will now be an object instead of an associative array. To get
an associative array, you will need to cast it as such:
*/
$decoded_array = (array) $decoded;
echo "Decode:\n" . print_r($decoded_array, true) . "\n";
```
Example with a passphrase
-------------------------
```php
use Firebase\JWT\JWT;
use Firebase\JWT\Key;
// Your passphrase
$passphrase = '[YOUR_PASSPHRASE]';
// Your private key file with passphrase
// Can be generated with "ssh-keygen -t rsa -m pem"
$privateKeyFile = '/path/to/key-with-passphrase.pem';
/** @var OpenSSLAsymmetricKey $privateKey */
$privateKey = openssl_pkey_get_private(
file_get_contents($privateKeyFile),
$passphrase
);
$payload = [
'iss' => 'example.org',
'aud' => 'example.com',
'iat' => 1356999524,
'nbf' => 1357000000
];
$jwt = JWT::encode($payload, $privateKey, 'RS256');
echo "Encode:\n" . print_r($jwt, true) . "\n";
// Get public key from the private key, or pull from from a file.
$publicKey = openssl_pkey_get_details($privateKey)['key'];
$decoded = JWT::decode($jwt, new Key($publicKey, 'RS256'));
echo "Decode:\n" . print_r((array) $decoded, true) . "\n";
```
Example with EdDSA (libsodium and Ed25519 signature)
----------------------------
```php
use Firebase\JWT\JWT;
use Firebase\JWT\Key;
// Public and private keys are expected to be Base64 encoded. The last
// non-empty line is used so that keys can be generated with
// sodium_crypto_sign_keypair(). The secret keys generated by other tools may
// need to be adjusted to match the input expected by libsodium.
$keyPair = sodium_crypto_sign_keypair();
$privateKey = base64_encode(sodium_crypto_sign_secretkey($keyPair));
$publicKey = base64_encode(sodium_crypto_sign_publickey($keyPair));
$payload = [
'iss' => 'example.org',
'aud' => 'example.com',
'iat' => 1356999524,
'nbf' => 1357000000
];
$jwt = JWT::encode($payload, $privateKey, 'EdDSA');
echo "Encode:\n" . print_r($jwt, true) . "\n";
$decoded = JWT::decode($jwt, new Key($publicKey, 'EdDSA'));
echo "Decode:\n" . print_r((array) $decoded, true) . "\n";
````
Example with multiple keys
--------------------------
```php
use Firebase\JWT\JWT;
use Firebase\JWT\Key;
// Example RSA keys from previous example
// $privateKey1 = '...';
// $publicKey1 = '...';
// Example EdDSA keys from previous example
// $privateKey2 = '...';
// $publicKey2 = '...';
$payload = [
'iss' => 'example.org',
'aud' => 'example.com',
'iat' => 1356999524,
'nbf' => 1357000000
];
$jwt1 = JWT::encode($payload, $privateKey1, 'RS256', 'kid1');
$jwt2 = JWT::encode($payload, $privateKey2, 'EdDSA', 'kid2');
echo "Encode 1:\n" . print_r($jwt1, true) . "\n";
echo "Encode 2:\n" . print_r($jwt2, true) . "\n";
$keys = [
'kid1' => new Key($publicKey1, 'RS256'),
'kid2' => new Key($publicKey2, 'EdDSA'),
];
$decoded1 = JWT::decode($jwt1, $keys);
$decoded2 = JWT::decode($jwt2, $keys);
echo "Decode 1:\n" . print_r((array) $decoded1, true) . "\n";
echo "Decode 2:\n" . print_r((array) $decoded2, true) . "\n";
```
Using JWKs
----------
```php
use Firebase\JWT\JWK;
use Firebase\JWT\JWT;
// Set of keys. The "keys" key is required. For example, the JSON response to
// this endpoint: https://www.gstatic.com/iap/verify/public_key-jwk
$jwks = ['keys' => []];
// JWK::parseKeySet($jwks) returns an associative array of **kid** to Firebase\JWT\Key
// objects. Pass this as the second parameter to JWT::decode.
JWT::decode($jwt, JWK::parseKeySet($jwks));
```
Using Cached Key Sets
---------------------
The `CachedKeySet` class can be used to fetch and cache JWKS (JSON Web Key Sets) from a public URI.
This has the following advantages:
1. The results are cached for performance.
2. If an unrecognized key is requested, the cache is refreshed, to accomodate for key rotation.
3. If rate limiting is enabled, the JWKS URI will not make more than 10 requests a second.
```php
use Firebase\JWT\CachedKeySet;
use Firebase\JWT\JWT;
// The URI for the JWKS you wish to cache the results from
$jwksUri = 'https://www.gstatic.com/iap/verify/public_key-jwk';
// Create an HTTP client (can be any PSR-7 compatible HTTP client)
$httpClient = new GuzzleHttp\Client();
// Create an HTTP request factory (can be any PSR-17 compatible HTTP request factory)
$httpFactory = new GuzzleHttp\Psr\HttpFactory();
// Create a cache item pool (can be any PSR-6 compatible cache item pool)
$cacheItemPool = Phpfastcache\CacheManager::getInstance('files');
$keySet = new CachedKeySet(
$jwksUri,
$httpClient,
$httpFactory,
$cacheItemPool,
null, // $expiresAfter int seconds to set the JWKS to expire
true // $rateLimit true to enable rate limit of 10 RPS on lookup of invalid keys
);
$jwt = 'eyJhbGci...'; // Some JWT signed by a key from the $jwkUri above
$decoded = JWT::decode($jwt, $keySet);
```
Miscellaneous
-------------
#### Exception Handling
When a call to `JWT::decode` is invalid, it will throw one of the following exceptions:
```php
use Firebase\JWT\JWT;
use Firebase\JWT\SignatureInvalidException;
use Firebase\JWT\BeforeValidException;
use Firebase\JWT\ExpiredException;
use DomainException;
use InvalidArgumentException;
use UnexpectedValueException;
try {
$decoded = JWT::decode($jwt, $keys);
} catch (InvalidArgumentException $e) {
// provided key/key-array is empty or malformed.
} catch (DomainException $e) {
// provided algorithm is unsupported OR
// provided key is invalid OR
// unknown error thrown in openSSL or libsodium OR
// libsodium is required but not available.
} catch (SignatureInvalidException $e) {
// provided JWT signature verification failed.
} catch (BeforeValidException $e) {
// provided JWT is trying to be used before "nbf" claim OR
// provided JWT is trying to be used before "iat" claim.
} catch (ExpiredException $e) {
// provided JWT is trying to be used after "exp" claim.
} catch (UnexpectedValueException $e) {
// provided JWT is malformed OR
// provided JWT is missing an algorithm / using an unsupported algorithm OR
// provided JWT algorithm does not match provided key OR
// provided key ID in key/key-array is empty or invalid.
}
```
All exceptions in the `Firebase\JWT` namespace extend `UnexpectedValueException`, and can be simplified
like this:
```php
use Firebase\JWT\JWT;
use UnexpectedValueException;
try {
$decoded = JWT::decode($jwt, $keys);
} catch (LogicException $e) {
// errors having to do with environmental setup or malformed JWT Keys
} catch (UnexpectedValueException $e) {
// errors having to do with JWT signature and claims
}
```
#### Casting to array
The return value of `JWT::decode` is the generic PHP object `stdClass`. If you'd like to handle with arrays
instead, you can do the following:
```php
// return type is stdClass
$decoded = JWT::decode($jwt, $keys);
// cast to array
$decoded = json_decode(json_encode($decoded), true);
```
Tests
-----
Run the tests using phpunit:
```bash
$ pear install PHPUnit
$ phpunit --configuration phpunit.xml.dist
PHPUnit 3.7.10 by Sebastian Bergmann.
.....
Time: 0 seconds, Memory: 2.50Mb
OK (5 tests, 5 assertions)
```
New Lines in private keys
-----
If your private key contains `\n` characters, be sure to wrap it in double quotes `""`
and not single quotes `''` in order to properly interpret the escaped characters.
License
-------
[3-Clause BSD](http://opensource.org/licenses/BSD-3-Clause).

View File

@@ -0,0 +1,18 @@
<?php
namespace Firebase\JWT;
class BeforeValidException extends \UnexpectedValueException implements JWTExceptionWithPayloadInterface
{
private object $payload;
public function setPayload(object $payload): void
{
$this->payload = $payload;
}
public function getPayload(): object
{
return $this->payload;
}
}

View File

@@ -0,0 +1,274 @@
<?php
namespace Firebase\JWT;
use ArrayAccess;
use InvalidArgumentException;
use LogicException;
use OutOfBoundsException;
use Psr\Cache\CacheItemInterface;
use Psr\Cache\CacheItemPoolInterface;
use Psr\Http\Client\ClientInterface;
use Psr\Http\Message\RequestFactoryInterface;
use RuntimeException;
use UnexpectedValueException;
/**
* @implements ArrayAccess<string, Key>
*/
class CachedKeySet implements ArrayAccess
{
/**
* @var string
*/
private $jwksUri;
/**
* @var ClientInterface
*/
private $httpClient;
/**
* @var RequestFactoryInterface
*/
private $httpFactory;
/**
* @var CacheItemPoolInterface
*/
private $cache;
/**
* @var ?int
*/
private $expiresAfter;
/**
* @var ?CacheItemInterface
*/
private $cacheItem;
/**
* @var array<string, array<mixed>>
*/
private $keySet;
/**
* @var string
*/
private $cacheKey;
/**
* @var string
*/
private $cacheKeyPrefix = 'jwks';
/**
* @var int
*/
private $maxKeyLength = 64;
/**
* @var bool
*/
private $rateLimit;
/**
* @var string
*/
private $rateLimitCacheKey;
/**
* @var int
*/
private $maxCallsPerMinute = 10;
/**
* @var string|null
*/
private $defaultAlg;
public function __construct(
string $jwksUri,
ClientInterface $httpClient,
RequestFactoryInterface $httpFactory,
CacheItemPoolInterface $cache,
?int $expiresAfter = null,
bool $rateLimit = false,
?string $defaultAlg = null
) {
$this->jwksUri = $jwksUri;
$this->httpClient = $httpClient;
$this->httpFactory = $httpFactory;
$this->cache = $cache;
$this->expiresAfter = $expiresAfter;
$this->rateLimit = $rateLimit;
$this->defaultAlg = $defaultAlg;
$this->setCacheKeys();
}
/**
* @param string $keyId
* @return Key
*/
public function offsetGet($keyId): Key
{
if (!$this->keyIdExists($keyId)) {
throw new OutOfBoundsException('Key ID not found');
}
return JWK::parseKey($this->keySet[$keyId], $this->defaultAlg);
}
/**
* @param string $keyId
* @return bool
*/
public function offsetExists($keyId): bool
{
return $this->keyIdExists($keyId);
}
/**
* @param string $offset
* @param Key $value
*/
public function offsetSet($offset, $value): void
{
throw new LogicException('Method not implemented');
}
/**
* @param string $offset
*/
public function offsetUnset($offset): void
{
throw new LogicException('Method not implemented');
}
/**
* @return array<mixed>
*/
private function formatJwksForCache(string $jwks): array
{
$jwks = json_decode($jwks, true);
if (!isset($jwks['keys'])) {
throw new UnexpectedValueException('"keys" member must exist in the JWK Set');
}
if (empty($jwks['keys'])) {
throw new InvalidArgumentException('JWK Set did not contain any keys');
}
$keys = [];
foreach ($jwks['keys'] as $k => $v) {
$kid = isset($v['kid']) ? $v['kid'] : $k;
$keys[(string) $kid] = $v;
}
return $keys;
}
private function keyIdExists(string $keyId): bool
{
if (null === $this->keySet) {
$item = $this->getCacheItem();
// Try to load keys from cache
if ($item->isHit()) {
// item found! retrieve it
$this->keySet = $item->get();
// If the cached item is a string, the JWKS response was cached (previous behavior).
// Parse this into expected format array<kid, jwk> instead.
if (\is_string($this->keySet)) {
$this->keySet = $this->formatJwksForCache($this->keySet);
}
}
}
if (!isset($this->keySet[$keyId])) {
if ($this->rateLimitExceeded()) {
return false;
}
$request = $this->httpFactory->createRequest('GET', $this->jwksUri);
$jwksResponse = $this->httpClient->sendRequest($request);
if ($jwksResponse->getStatusCode() !== 200) {
throw new UnexpectedValueException(
\sprintf('HTTP Error: %d %s for URI "%s"',
$jwksResponse->getStatusCode(),
$jwksResponse->getReasonPhrase(),
$this->jwksUri,
),
$jwksResponse->getStatusCode()
);
}
$this->keySet = $this->formatJwksForCache((string) $jwksResponse->getBody());
if (!isset($this->keySet[$keyId])) {
return false;
}
$item = $this->getCacheItem();
$item->set($this->keySet);
if ($this->expiresAfter) {
$item->expiresAfter($this->expiresAfter);
}
$this->cache->save($item);
}
return true;
}
private function rateLimitExceeded(): bool
{
if (!$this->rateLimit) {
return false;
}
$cacheItem = $this->cache->getItem($this->rateLimitCacheKey);
$cacheItemData = [];
if ($cacheItem->isHit() && \is_array($data = $cacheItem->get())) {
$cacheItemData = $data;
}
$callsPerMinute = $cacheItemData['callsPerMinute'] ?? 0;
$expiry = $cacheItemData['expiry'] ?? new \DateTime('+60 seconds', new \DateTimeZone('UTC'));
if (++$callsPerMinute > $this->maxCallsPerMinute) {
return true;
}
$cacheItem->set(['expiry' => $expiry, 'callsPerMinute' => $callsPerMinute]);
$cacheItem->expiresAt($expiry);
$this->cache->save($cacheItem);
return false;
}
private function getCacheItem(): CacheItemInterface
{
if (\is_null($this->cacheItem)) {
$this->cacheItem = $this->cache->getItem($this->cacheKey);
}
return $this->cacheItem;
}
private function setCacheKeys(): void
{
if (empty($this->jwksUri)) {
throw new RuntimeException('JWKS URI is empty');
}
// ensure we do not have illegal characters
$key = preg_replace('|[^a-zA-Z0-9_\.!]|', '', $this->jwksUri);
// add prefix
$key = $this->cacheKeyPrefix . $key;
// Hash keys if they exceed $maxKeyLength of 64
if (\strlen($key) > $this->maxKeyLength) {
$key = substr(hash('sha256', $key), 0, $this->maxKeyLength);
}
$this->cacheKey = $key;
if ($this->rateLimit) {
// add prefix
$rateLimitKey = $this->cacheKeyPrefix . 'ratelimit' . $key;
// Hash keys if they exceed $maxKeyLength of 64
if (\strlen($rateLimitKey) > $this->maxKeyLength) {
$rateLimitKey = substr(hash('sha256', $rateLimitKey), 0, $this->maxKeyLength);
}
$this->rateLimitCacheKey = $rateLimitKey;
}
}
}

View File

@@ -0,0 +1,30 @@
<?php
namespace Firebase\JWT;
class ExpiredException extends \UnexpectedValueException implements JWTExceptionWithPayloadInterface
{
private object $payload;
private ?int $timestamp = null;
public function setPayload(object $payload): void
{
$this->payload = $payload;
}
public function getPayload(): object
{
return $this->payload;
}
public function setTimestamp(int $timestamp): void
{
$this->timestamp = $timestamp;
}
public function getTimestamp(): ?int
{
return $this->timestamp;
}
}

355
src/jwt/src/JWK.php Normal file
View File

@@ -0,0 +1,355 @@
<?php
namespace Firebase\JWT;
use DomainException;
use InvalidArgumentException;
use UnexpectedValueException;
/**
* JSON Web Key implementation, based on this spec:
* https://tools.ietf.org/html/draft-ietf-jose-json-web-key-41
*
* PHP version 5
*
* @category Authentication
* @package Authentication_JWT
* @author Bui Sy Nguyen <nguyenbs@gmail.com>
* @license http://opensource.org/licenses/BSD-3-Clause 3-clause BSD
* @link https://github.com/firebase/php-jwt
*/
class JWK
{
private const OID = '1.2.840.10045.2.1';
private const ASN1_OBJECT_IDENTIFIER = 0x06;
private const ASN1_SEQUENCE = 0x10; // also defined in JWT
private const ASN1_BIT_STRING = 0x03;
private const EC_CURVES = [
'P-256' => '1.2.840.10045.3.1.7', // Len: 64
'secp256k1' => '1.3.132.0.10', // Len: 64
'P-384' => '1.3.132.0.34', // Len: 96
// 'P-521' => '1.3.132.0.35', // Len: 132 (not supported)
];
// For keys with "kty" equal to "OKP" (Octet Key Pair), the "crv" parameter must contain the key subtype.
// This library supports the following subtypes:
private const OKP_SUBTYPES = [
'Ed25519' => true, // RFC 8037
];
/**
* Parse a set of JWK keys
*
* @param array<mixed> $jwks The JSON Web Key Set as an associative array
* @param string $defaultAlg The algorithm for the Key object if "alg" is not set in the
* JSON Web Key Set
*
* @return array<string, Key> An associative array of key IDs (kid) to Key objects
*
* @throws InvalidArgumentException Provided JWK Set is empty
* @throws UnexpectedValueException Provided JWK Set was invalid
* @throws DomainException OpenSSL failure
*
* @uses parseKey
*/
public static function parseKeySet(#[\SensitiveParameter] array $jwks, ?string $defaultAlg = null): array
{
$keys = [];
if (!isset($jwks['keys'])) {
throw new UnexpectedValueException('"keys" member must exist in the JWK Set');
}
if (empty($jwks['keys'])) {
throw new InvalidArgumentException('JWK Set did not contain any keys');
}
foreach ($jwks['keys'] as $k => $v) {
$kid = isset($v['kid']) ? $v['kid'] : $k;
if ($key = self::parseKey($v, $defaultAlg)) {
$keys[(string) $kid] = $key;
}
}
if (0 === \count($keys)) {
throw new UnexpectedValueException('No supported algorithms found in JWK Set');
}
return $keys;
}
/**
* Parse a JWK key
*
* @param array<mixed> $jwk An individual JWK
* @param string $defaultAlg The algorithm for the Key object if "alg" is not set in the
* JSON Web Key Set
*
* @return Key The key object for the JWK
*
* @throws InvalidArgumentException Provided JWK is empty
* @throws UnexpectedValueException Provided JWK was invalid
* @throws DomainException OpenSSL failure
*
* @uses createPemFromModulusAndExponent
*/
public static function parseKey(#[\SensitiveParameter] array $jwk, ?string $defaultAlg = null): ?Key
{
if (empty($jwk)) {
throw new InvalidArgumentException('JWK must not be empty');
}
if (!isset($jwk['kty'])) {
throw new UnexpectedValueException('JWK must contain a "kty" parameter');
}
if (!isset($jwk['alg'])) {
if (\is_null($defaultAlg)) {
// The "alg" parameter is optional in a KTY, but an algorithm is required
// for parsing in this library. Use the $defaultAlg parameter when parsing the
// key set in order to prevent this error.
// @see https://datatracker.ietf.org/doc/html/rfc7517#section-4.4
throw new UnexpectedValueException('JWK must contain an "alg" parameter');
}
$jwk['alg'] = $defaultAlg;
}
switch ($jwk['kty']) {
case 'RSA':
if (!empty($jwk['d'])) {
throw new UnexpectedValueException('RSA private keys are not supported');
}
if (!isset($jwk['n']) || !isset($jwk['e'])) {
throw new UnexpectedValueException('RSA keys must contain values for both "n" and "e"');
}
$pem = self::createPemFromModulusAndExponent($jwk['n'], $jwk['e']);
$publicKey = \openssl_pkey_get_public($pem);
if (false === $publicKey) {
throw new DomainException(
'OpenSSL error: ' . \openssl_error_string()
);
}
return new Key($publicKey, $jwk['alg']);
case 'EC':
if (isset($jwk['d'])) {
// The key is actually a private key
throw new UnexpectedValueException('Key data must be for a public key');
}
if (empty($jwk['crv'])) {
throw new UnexpectedValueException('crv not set');
}
if (!isset(self::EC_CURVES[$jwk['crv']])) {
throw new DomainException('Unrecognised or unsupported EC curve');
}
if (empty($jwk['x']) || empty($jwk['y'])) {
throw new UnexpectedValueException('x and y not set');
}
$publicKey = self::createPemFromCrvAndXYCoordinates($jwk['crv'], $jwk['x'], $jwk['y']);
return new Key($publicKey, $jwk['alg']);
case 'OKP':
if (isset($jwk['d'])) {
// The key is actually a private key
throw new UnexpectedValueException('Key data must be for a public key');
}
if (!isset($jwk['crv'])) {
throw new UnexpectedValueException('crv not set');
}
if (empty(self::OKP_SUBTYPES[$jwk['crv']])) {
throw new DomainException('Unrecognised or unsupported OKP key subtype');
}
if (empty($jwk['x'])) {
throw new UnexpectedValueException('x not set');
}
// This library works internally with EdDSA keys (Ed25519) encoded in standard base64.
$publicKey = JWT::convertBase64urlToBase64($jwk['x']);
return new Key($publicKey, $jwk['alg']);
case 'oct':
if (!isset($jwk['k'])) {
throw new UnexpectedValueException('k not set');
}
return new Key(JWT::urlsafeB64Decode($jwk['k']), $jwk['alg']);
default:
break;
}
return null;
}
/**
* Converts the EC JWK values to pem format.
*
* @param string $crv The EC curve (only P-256 & P-384 is supported)
* @param string $x The EC x-coordinate
* @param string $y The EC y-coordinate
*
* @return string
*/
private static function createPemFromCrvAndXYCoordinates(string $crv, string $x, string $y): string
{
$pem =
self::encodeDER(
self::ASN1_SEQUENCE,
self::encodeDER(
self::ASN1_SEQUENCE,
self::encodeDER(
self::ASN1_OBJECT_IDENTIFIER,
self::encodeOID(self::OID)
)
. self::encodeDER(
self::ASN1_OBJECT_IDENTIFIER,
self::encodeOID(self::EC_CURVES[$crv])
)
) .
self::encodeDER(
self::ASN1_BIT_STRING,
\chr(0x00) . \chr(0x04)
. JWT::urlsafeB64Decode($x)
. JWT::urlsafeB64Decode($y)
)
);
return \sprintf(
"-----BEGIN PUBLIC KEY-----\n%s\n-----END PUBLIC KEY-----\n",
wordwrap(base64_encode($pem), 64, "\n", true)
);
}
/**
* Create a public key represented in PEM format from RSA modulus and exponent information
*
* @param string $n The RSA modulus encoded in Base64
* @param string $e The RSA exponent encoded in Base64
*
* @return string The RSA public key represented in PEM format
*
* @uses encodeLength
*/
private static function createPemFromModulusAndExponent(
string $n,
string $e
): string {
$mod = JWT::urlsafeB64Decode($n);
$exp = JWT::urlsafeB64Decode($e);
$modulus = \pack('Ca*a*', 2, self::encodeLength(\strlen($mod)), $mod);
$publicExponent = \pack('Ca*a*', 2, self::encodeLength(\strlen($exp)), $exp);
$rsaPublicKey = \pack(
'Ca*a*a*',
48,
self::encodeLength(\strlen($modulus) + \strlen($publicExponent)),
$modulus,
$publicExponent
);
// sequence(oid(1.2.840.113549.1.1.1), null)) = rsaEncryption.
$rsaOID = \pack('H*', '300d06092a864886f70d0101010500'); // hex version of MA0GCSqGSIb3DQEBAQUA
$rsaPublicKey = \chr(0) . $rsaPublicKey;
$rsaPublicKey = \chr(3) . self::encodeLength(\strlen($rsaPublicKey)) . $rsaPublicKey;
$rsaPublicKey = \pack(
'Ca*a*',
48,
self::encodeLength(\strlen($rsaOID . $rsaPublicKey)),
$rsaOID . $rsaPublicKey
);
return "-----BEGIN PUBLIC KEY-----\r\n" .
\chunk_split(\base64_encode($rsaPublicKey), 64) .
'-----END PUBLIC KEY-----';
}
/**
* DER-encode the length
*
* DER supports lengths up to (2**8)**127, however, we'll only support lengths up to (2**8)**4. See
* {@link http://itu.int/ITU-T/studygroups/com17/languages/X.690-0207.pdf#p=13 X.690 paragraph 8.1.3} for more information.
*
* @param int $length
* @return string
*/
private static function encodeLength(int $length): string
{
if ($length <= 0x7F) {
return \chr($length);
}
$temp = \ltrim(\pack('N', $length), \chr(0));
return \pack('Ca*', 0x80 | \strlen($temp), $temp);
}
/**
* Encodes a value into a DER object.
* Also defined in Firebase\JWT\JWT
*
* @param int $type DER tag
* @param string $value the value to encode
* @return string the encoded object
*/
private static function encodeDER(int $type, string $value): string
{
$tag_header = 0;
if ($type === self::ASN1_SEQUENCE) {
$tag_header |= 0x20;
}
// Type
$der = \chr($tag_header | $type);
// Length
$der .= \chr(\strlen($value));
return $der . $value;
}
/**
* Encodes a string into a DER-encoded OID.
*
* @param string $oid the OID string
* @return string the binary DER-encoded OID
*/
private static function encodeOID(string $oid): string
{
$octets = explode('.', $oid);
// Get the first octet
$first = (int) array_shift($octets);
$second = (int) array_shift($octets);
$oid = \chr($first * 40 + $second);
// Iterate over subsequent octets
foreach ($octets as $octet) {
if ($octet == 0) {
$oid .= \chr(0x00);
continue;
}
$bin = '';
while ($octet) {
$bin .= \chr(0x80 | ($octet & 0x7f));
$octet >>= 7;
}
$bin[0] = $bin[0] & \chr(0x7f);
// Convert to big endian if necessary
if (pack('V', 65534) == pack('L', 65534)) {
$oid .= strrev($bin);
} else {
$oid .= $bin;
}
}
return $oid;
}
}

748
src/jwt/src/JWT.php Normal file
View File

@@ -0,0 +1,748 @@
<?php
namespace Firebase\JWT;
use ArrayAccess;
use DateTime;
use DomainException;
use Exception;
use InvalidArgumentException;
use OpenSSLAsymmetricKey;
use OpenSSLCertificate;
use stdClass;
use UnexpectedValueException;
/**
* JSON Web Token implementation, based on this spec:
* https://tools.ietf.org/html/rfc7519
*
* PHP version 5
*
* @category Authentication
* @package Authentication_JWT
* @author Neuman Vong <neuman@twilio.com>
* @author Anant Narayanan <anant@php.net>
* @license http://opensource.org/licenses/BSD-3-Clause 3-clause BSD
* @link https://github.com/firebase/php-jwt
*/
class JWT
{
private const ASN1_INTEGER = 0x02;
private const ASN1_SEQUENCE = 0x10;
private const ASN1_BIT_STRING = 0x03;
private const RSA_KEY_MIN_LENGTH=2048;
/**
* When checking nbf, iat or expiration times,
* we want to provide some extra leeway time to
* account for clock skew.
*
* @var int
*/
public static $leeway = 0;
/**
* Allow the current timestamp to be specified.
* Useful for fixing a value within unit testing.
* Will default to PHP time() value if null.
*
* @var ?int
*/
public static $timestamp = null;
/**
* @var array<string, string[]>
*/
public static $supported_algs = [
'ES384' => ['openssl', 'SHA384'],
'ES256' => ['openssl', 'SHA256'],
'ES256K' => ['openssl', 'SHA256'],
'HS256' => ['hash_hmac', 'SHA256'],
'HS384' => ['hash_hmac', 'SHA384'],
'HS512' => ['hash_hmac', 'SHA512'],
'RS256' => ['openssl', 'SHA256'],
'RS384' => ['openssl', 'SHA384'],
'RS512' => ['openssl', 'SHA512'],
'EdDSA' => ['sodium_crypto', 'EdDSA'],
];
/**
* Decodes a JWT string into a PHP object.
*
* @param string $jwt The JWT
* @param Key|ArrayAccess<string,Key>|array<string,Key> $keyOrKeyArray The Key or associative array of key IDs
* (kid) to Key objects.
* If the algorithm used is asymmetric, this is
* the public key.
* Each Key object contains an algorithm and
* matching key.
* Supported algorithms are 'ES384','ES256',
* 'HS256', 'HS384', 'HS512', 'RS256', 'RS384'
* and 'RS512'.
* @param stdClass $headers Optional. Populates stdClass with headers.
*
* @return stdClass The JWT's payload as a PHP object
*
* @throws InvalidArgumentException Provided key/key-array was empty or malformed
* @throws DomainException Provided JWT is malformed
* @throws UnexpectedValueException Provided JWT was invalid
* @throws SignatureInvalidException Provided JWT was invalid because the signature verification failed
* @throws BeforeValidException Provided JWT is trying to be used before it's eligible as defined by 'nbf'
* @throws BeforeValidException Provided JWT is trying to be used before it's been created as defined by 'iat'
* @throws ExpiredException Provided JWT has since expired, as defined by the 'exp' claim
*
* @uses jsonDecode
* @uses urlsafeB64Decode
*/
public static function decode(
string $jwt,
#[\SensitiveParameter] $keyOrKeyArray,
?stdClass &$headers = null
): stdClass {
// Validate JWT
$timestamp = \is_null(static::$timestamp) ? \time() : static::$timestamp;
if (empty($keyOrKeyArray)) {
throw new InvalidArgumentException('Key may not be empty');
}
$tks = \explode('.', $jwt);
if (\count($tks) !== 3) {
throw new UnexpectedValueException('Wrong number of segments');
}
list($headb64, $bodyb64, $cryptob64) = $tks;
$headerRaw = static::urlsafeB64Decode($headb64);
if (null === ($header = static::jsonDecode($headerRaw))) {
throw new UnexpectedValueException('Invalid header encoding');
}
if ($headers !== null) {
$headers = $header;
}
$payloadRaw = static::urlsafeB64Decode($bodyb64);
if (null === ($payload = static::jsonDecode($payloadRaw))) {
throw new UnexpectedValueException('Invalid claims encoding');
}
if (\is_array($payload)) {
// prevent PHP Fatal Error in edge-cases when payload is empty array
$payload = (object) $payload;
}
if (!$payload instanceof stdClass) {
throw new UnexpectedValueException('Payload must be a JSON object');
}
if (isset($payload->iat) && !\is_numeric($payload->iat)) {
throw new UnexpectedValueException('Payload iat must be a number');
}
if (isset($payload->nbf) && !\is_numeric($payload->nbf)) {
throw new UnexpectedValueException('Payload nbf must be a number');
}
if (isset($payload->exp) && !\is_numeric($payload->exp)) {
throw new UnexpectedValueException('Payload exp must be a number');
}
$sig = static::urlsafeB64Decode($cryptob64);
if (empty($header->alg)) {
throw new UnexpectedValueException('Empty algorithm');
}
if (empty(static::$supported_algs[$header->alg])) {
throw new UnexpectedValueException('Algorithm not supported');
}
$key = self::getKey($keyOrKeyArray, property_exists($header, 'kid') ? $header->kid : null);
// Check the algorithm
if (!self::constantTimeEquals($key->getAlgorithm(), $header->alg)) {
// See issue #351
throw new UnexpectedValueException('Incorrect key for this algorithm');
}
if (\in_array($header->alg, ['ES256', 'ES256K', 'ES384'], true)) {
// OpenSSL expects an ASN.1 DER sequence for ES256/ES256K/ES384 signatures
$sig = self::signatureToDER($sig);
}
if (!self::verify("{$headb64}.{$bodyb64}", $sig, $key->getKeyMaterial(), $header->alg)) {
throw new SignatureInvalidException('Signature verification failed');
}
// Check the nbf if it is defined. This is the time that the
// token can actually be used. If it's not yet that time, abort.
if (isset($payload->nbf) && floor($payload->nbf) > ($timestamp + static::$leeway)) {
$ex = new BeforeValidException(
'Cannot handle token with nbf prior to ' . \date(DateTime::ATOM, (int) floor($payload->nbf))
);
$ex->setPayload($payload);
throw $ex;
}
// Check that this token has been created before 'now'. This prevents
// using tokens that have been created for later use (and haven't
// correctly used the nbf claim).
if (!isset($payload->nbf) && isset($payload->iat) && floor($payload->iat) > ($timestamp + static::$leeway)) {
$ex = new BeforeValidException(
'Cannot handle token with iat prior to ' . \date(DateTime::ATOM, (int) floor($payload->iat))
);
$ex->setPayload($payload);
throw $ex;
}
// Check if this token has expired.
if (isset($payload->exp) && ($timestamp - static::$leeway) >= $payload->exp) {
$ex = new ExpiredException('Expired token');
$ex->setPayload($payload);
$ex->setTimestamp($timestamp);
throw $ex;
}
return $payload;
}
/**
* Converts and signs a PHP array into a JWT string.
*
* @param array<mixed> $payload PHP array
* @param string|OpenSSLAsymmetricKey|OpenSSLCertificate $key The secret key.
* @param string $alg Supported algorithms are 'ES384','ES256', 'ES256K', 'HS256',
* 'HS384', 'HS512', 'RS256', 'RS384', and 'RS512'
* @param string $keyId
* @param array<string, string|string[]> $head An array with header elements to attach
*
* @return string A signed JWT
*
* @uses jsonEncode
* @uses urlsafeB64Encode
*/
public static function encode(
array $payload,
#[\SensitiveParameter] $key,
string $alg,
?string $keyId = null,
?array $head = null
): string {
$header = ['typ' => 'JWT'];
if (isset($head)) {
$header = \array_merge($header, $head);
}
$header['alg'] = $alg;
if ($keyId !== null) {
$header['kid'] = $keyId;
}
$segments = [];
$segments[] = static::urlsafeB64Encode((string) static::jsonEncode($header));
$segments[] = static::urlsafeB64Encode((string) static::jsonEncode($payload));
$signing_input = \implode('.', $segments);
$signature = static::sign($signing_input, $key, $alg);
$segments[] = static::urlsafeB64Encode($signature);
return \implode('.', $segments);
}
/**
* Sign a string with a given key and algorithm.
*
* @param string $msg The message to sign
* @param string|OpenSSLAsymmetricKey|OpenSSLCertificate $key The secret key.
* @param string $alg Supported algorithms are 'EdDSA', 'ES384', 'ES256', 'ES256K', 'HS256',
* 'HS384', 'HS512', 'RS256', 'RS384', and 'RS512'
*
* @return string An encrypted message
*
* @throws DomainException Unsupported algorithm or bad key was specified
*/
public static function sign(
string $msg,
#[\SensitiveParameter] $key,
string $alg
): string {
if (empty(static::$supported_algs[$alg])) {
throw new DomainException('Algorithm not supported');
}
list($function, $algorithm) = static::$supported_algs[$alg];
switch ($function) {
case 'hash_hmac':
if (!\is_string($key)) {
throw new InvalidArgumentException('key must be a string when using hmac');
}
self::validateHmacKeyLength($key, $algorithm);
return \hash_hmac($algorithm, $msg, $key, true);
case 'openssl':
$signature = '';
if (!$key = openssl_pkey_get_private($key)) {
throw new DomainException('OpenSSL unable to validate key');
}
if (str_starts_with($alg, 'RS')) {
self::validateRsaKeyLength($key);
} elseif (str_starts_with($alg, 'ES')) {
self::validateEcKeyLength($key, $alg);
}
$success = \openssl_sign($msg, $signature, $key, $algorithm);
if (!$success) {
throw new DomainException('OpenSSL unable to sign data');
}
if ($alg === 'ES256' || $alg === 'ES256K') {
$signature = self::signatureFromDER($signature, 256);
} elseif ($alg === 'ES384') {
$signature = self::signatureFromDER($signature, 384);
}
return $signature;
case 'sodium_crypto':
if (!\function_exists('sodium_crypto_sign_detached')) {
throw new DomainException('libsodium is not available');
}
if (!\is_string($key)) {
throw new InvalidArgumentException('key must be a string when using EdDSA');
}
try {
// The last non-empty line is used as the key.
$lines = array_filter(explode("\n", $key));
$key = base64_decode((string) end($lines));
if (\strlen($key) === 0) {
throw new DomainException('Key cannot be empty string');
}
return sodium_crypto_sign_detached($msg, $key);
} catch (Exception $e) {
throw new DomainException($e->getMessage(), 0, $e);
}
}
throw new DomainException('Algorithm not supported');
}
/**
* Verify a signature with the message, key and method. Not all methods
* are symmetric, so we must have a separate verify and sign method.
*
* @param string $msg The original message (header and body)
* @param string $signature The original signature
* @param string|OpenSSLAsymmetricKey|OpenSSLCertificate $keyMaterial For Ed*, ES*, HS*, a string key works. for RS*, must be an instance of OpenSSLAsymmetricKey
* @param string $alg The algorithm
*
* @return bool
*
* @throws DomainException Invalid Algorithm, bad key, or OpenSSL failure
*/
private static function verify(
string $msg,
string $signature,
#[\SensitiveParameter] $keyMaterial,
string $alg
): bool {
if (empty(static::$supported_algs[$alg])) {
throw new DomainException('Algorithm not supported');
}
list($function, $algorithm) = static::$supported_algs[$alg];
switch ($function) {
case 'openssl':
if (!$key = openssl_pkey_get_public($keyMaterial)) {
throw new DomainException('OpenSSL unable to validate key');
}
if (str_starts_with($alg, 'RS')) {
self::validateRsaKeyLength($key);
} elseif (str_starts_with($alg, 'ES')) {
self::validateEcKeyLength($key, $alg);
}
$success = \openssl_verify($msg, $signature, $keyMaterial, $algorithm);
if ($success === 1) {
return true;
}
if ($success === 0) {
return false;
}
// returns 1 on success, 0 on failure, -1 on error.
throw new DomainException(
'OpenSSL error: ' . \openssl_error_string()
);
case 'sodium_crypto':
if (!\function_exists('sodium_crypto_sign_verify_detached')) {
throw new DomainException('libsodium is not available');
}
if (!\is_string($keyMaterial)) {
throw new InvalidArgumentException('key must be a string when using EdDSA');
}
try {
// The last non-empty line is used as the key.
$lines = array_filter(explode("\n", $keyMaterial));
$key = base64_decode((string) end($lines));
if (\strlen($key) === 0) {
throw new DomainException('Key cannot be empty string');
}
if (\strlen($signature) === 0) {
throw new DomainException('Signature cannot be empty string');
}
return sodium_crypto_sign_verify_detached($signature, $msg, $key);
} catch (Exception $e) {
throw new DomainException($e->getMessage(), 0, $e);
}
case 'hash_hmac':
default:
if (!\is_string($keyMaterial)) {
throw new InvalidArgumentException('key must be a string when using hmac');
}
self::validateHmacKeyLength($keyMaterial, $algorithm);
$hash = \hash_hmac($algorithm, $msg, $keyMaterial, true);
return self::constantTimeEquals($hash, $signature);
}
}
/**
* Decode a JSON string into a PHP object.
*
* @param string $input JSON string
*
* @return mixed The decoded JSON string
*
* @throws DomainException Provided string was invalid JSON
*/
public static function jsonDecode(string $input)
{
$obj = \json_decode($input, false, 512, JSON_BIGINT_AS_STRING);
if ($errno = \json_last_error()) {
self::handleJsonError($errno);
} elseif ($obj === null && $input !== 'null') {
throw new DomainException('Null result with non-null input');
}
return $obj;
}
/**
* Encode a PHP array into a JSON string.
*
* @param array<mixed> $input A PHP array
*
* @return string JSON representation of the PHP array
*
* @throws DomainException Provided object could not be encoded to valid JSON
*/
public static function jsonEncode(array $input): string
{
$json = \json_encode($input, \JSON_UNESCAPED_SLASHES);
if ($errno = \json_last_error()) {
self::handleJsonError($errno);
} elseif ($json === 'null') {
throw new DomainException('Null result with non-null input');
}
if ($json === false) {
throw new DomainException('Provided object could not be encoded to valid JSON');
}
return $json;
}
/**
* Decode a string with URL-safe Base64.
*
* @param string $input A Base64 encoded string
*
* @return string A decoded string
*
* @throws InvalidArgumentException invalid base64 characters
*/
public static function urlsafeB64Decode(string $input): string
{
return \base64_decode(self::convertBase64UrlToBase64($input));
}
/**
* Convert a string in the base64url (URL-safe Base64) encoding to standard base64.
*
* @param string $input A Base64 encoded string with URL-safe characters (-_ and no padding)
*
* @return string A Base64 encoded string with standard characters (+/) and padding (=), when
* needed.
*
* @see https://www.rfc-editor.org/rfc/rfc4648
*/
public static function convertBase64UrlToBase64(string $input): string
{
$remainder = \strlen($input) % 4;
if ($remainder) {
$padlen = 4 - $remainder;
$input .= \str_repeat('=', $padlen);
}
return \strtr($input, '-_', '+/');
}
/**
* Encode a string with URL-safe Base64.
*
* @param string $input The string you want encoded
*
* @return string The base64 encode of what you passed in
*/
public static function urlsafeB64Encode(string $input): string
{
return \str_replace('=', '', \strtr(\base64_encode($input), '+/', '-_'));
}
/**
* Determine if an algorithm has been provided for each Key
*
* @param Key|ArrayAccess<string,Key>|array<string,Key> $keyOrKeyArray
* @param string|null $kid
*
* @throws UnexpectedValueException
*
* @return Key
*/
private static function getKey(
#[\SensitiveParameter] $keyOrKeyArray,
?string $kid
): Key {
if ($keyOrKeyArray instanceof Key) {
return $keyOrKeyArray;
}
if (empty($kid) && $kid !== '0') {
throw new UnexpectedValueException('"kid" empty, unable to lookup correct key');
}
if ($keyOrKeyArray instanceof CachedKeySet) {
// Skip "isset" check, as this will automatically refresh if not set
return $keyOrKeyArray[$kid];
}
if (!isset($keyOrKeyArray[$kid])) {
throw new UnexpectedValueException('"kid" invalid, unable to lookup correct key');
}
return $keyOrKeyArray[$kid];
}
/**
* @param string $left The string of known length to compare against
* @param string $right The user-supplied string
* @return bool
*/
public static function constantTimeEquals(string $left, string $right): bool
{
if (\function_exists('hash_equals')) {
return \hash_equals($left, $right);
}
$len = \min(self::safeStrlen($left), self::safeStrlen($right));
$status = 0;
for ($i = 0; $i < $len; $i++) {
$status |= (\ord($left[$i]) ^ \ord($right[$i]));
}
$status |= (self::safeStrlen($left) ^ self::safeStrlen($right));
return ($status === 0);
}
/**
* Helper method to create a JSON error.
*
* @param int $errno An error number from json_last_error()
*
* @throws DomainException
*
* @return void
*/
private static function handleJsonError(int $errno): void
{
$messages = [
JSON_ERROR_DEPTH => 'Maximum stack depth exceeded',
JSON_ERROR_STATE_MISMATCH => 'Invalid or malformed JSON',
JSON_ERROR_CTRL_CHAR => 'Unexpected control character found',
JSON_ERROR_SYNTAX => 'Syntax error, malformed JSON',
JSON_ERROR_UTF8 => 'Malformed UTF-8 characters' //PHP >= 5.3.3
];
throw new DomainException(
isset($messages[$errno])
? $messages[$errno]
: 'Unknown JSON error: ' . $errno
);
}
/**
* Get the number of bytes in cryptographic strings.
*
* @param string $str
*
* @return int
*/
private static function safeStrlen(string $str): int
{
if (\function_exists('mb_strlen')) {
return \mb_strlen($str, '8bit');
}
return \strlen($str);
}
/**
* Convert an ECDSA signature to an ASN.1 DER sequence
*
* @param string $sig The ECDSA signature to convert
* @return string The encoded DER object
*/
private static function signatureToDER(string $sig): string
{
// Separate the signature into r-value and s-value
$length = max(1, (int) (\strlen($sig) / 2));
list($r, $s) = \str_split($sig, $length);
// Trim leading zeros
$r = \ltrim($r, "\x00");
$s = \ltrim($s, "\x00");
// Convert r-value and s-value from unsigned big-endian integers to
// signed two's complement
if (\ord($r[0]) > 0x7f) {
$r = "\x00" . $r;
}
if (\ord($s[0]) > 0x7f) {
$s = "\x00" . $s;
}
return self::encodeDER(
self::ASN1_SEQUENCE,
self::encodeDER(self::ASN1_INTEGER, $r) .
self::encodeDER(self::ASN1_INTEGER, $s)
);
}
/**
* Encodes a value into a DER object.
*
* @param int $type DER tag
* @param string $value the value to encode
*
* @return string the encoded object
*/
private static function encodeDER(int $type, string $value): string
{
$tag_header = 0;
if ($type === self::ASN1_SEQUENCE) {
$tag_header |= 0x20;
}
// Type
$der = \chr($tag_header | $type);
// Length
$der .= \chr(\strlen($value));
return $der . $value;
}
/**
* Encodes signature from a DER object.
*
* @param string $der binary signature in DER format
* @param int $keySize the number of bits in the key
*
* @return string the signature
*/
private static function signatureFromDER(string $der, int $keySize): string
{
// OpenSSL returns the ECDSA signatures as a binary ASN.1 DER SEQUENCE
list($offset, $_) = self::readDER($der);
list($offset, $r) = self::readDER($der, $offset);
list($offset, $s) = self::readDER($der, $offset);
// Convert r-value and s-value from signed two's compliment to unsigned
// big-endian integers
$r = \ltrim($r, "\x00");
$s = \ltrim($s, "\x00");
// Pad out r and s so that they are $keySize bits long
$r = \str_pad($r, $keySize / 8, "\x00", STR_PAD_LEFT);
$s = \str_pad($s, $keySize / 8, "\x00", STR_PAD_LEFT);
return $r . $s;
}
/**
* Reads binary DER-encoded data and decodes into a single object
*
* @param string $der the binary data in DER format
* @param int $offset the offset of the data stream containing the object
* to decode
*
* @return array{int, string|null} the new offset and the decoded object
*/
private static function readDER(string $der, int $offset = 0): array
{
$pos = $offset;
$size = \strlen($der);
$constructed = (\ord($der[$pos]) >> 5) & 0x01;
$type = \ord($der[$pos++]) & 0x1f;
// Length
$len = \ord($der[$pos++]);
if ($len & 0x80) {
$n = $len & 0x1f;
$len = 0;
while ($n-- && $pos < $size) {
$len = ($len << 8) | \ord($der[$pos++]);
}
}
// Value
if ($type === self::ASN1_BIT_STRING) {
$pos++; // Skip the first contents octet (padding indicator)
$data = \substr($der, $pos, $len - 1);
$pos += $len - 1;
} elseif (!$constructed) {
$data = \substr($der, $pos, $len);
$pos += $len;
} else {
$data = null;
}
return [$pos, $data];
}
/**
* Validate HMAC key length
*
* @param string $key HMAC key material
* @param string $algorithm The algorithm
*
* @throws DomainException Provided key is too short
*/
private static function validateHmacKeyLength(string $key, string $algorithm): void
{
$keyLength = \strlen($key) * 8;
$minKeyLength = (int) \str_replace('SHA', '', $algorithm);
if ($keyLength < $minKeyLength) {
throw new DomainException('Provided key is too short');
}
}
/**
* Validate RSA key length
*
* @param OpenSSLAsymmetricKey $key RSA key material
* @throws DomainException Provided key is too short
*/
private static function validateRsaKeyLength(#[\SensitiveParameter] OpenSSLAsymmetricKey $key): void
{
if (!$keyDetails = openssl_pkey_get_details($key)) {
throw new DomainException('Unable to validate key');
}
if ($keyDetails['bits'] < self::RSA_KEY_MIN_LENGTH) {
throw new DomainException('Provided key is too short');
}
}
/**
* Validate RSA key length
*
* @param OpenSSLAsymmetricKey $key RSA key material
* @param string $algorithm The algorithm
* @throws DomainException Provided key is too short
*/
private static function validateEcKeyLength(
#[\SensitiveParameter] OpenSSLAsymmetricKey $key,
string $algorithm
): void {
if (!$keyDetails = openssl_pkey_get_details($key)) {
throw new DomainException('Unable to validate key');
}
$minKeyLength = (int) \str_replace('ES', '', $algorithm);
if ($keyDetails['bits'] < $minKeyLength) {
throw new DomainException('Provided key is too short');
}
}
}

View File

@@ -0,0 +1,20 @@
<?php
namespace Firebase\JWT;
interface JWTExceptionWithPayloadInterface
{
/**
* Get the payload that caused this exception.
*
* @return object
*/
public function getPayload(): object;
/**
* Get the payload that caused this exception.
*
* @param object $payload
* @return void
*/
public function setPayload(object $payload): void;
}

54
src/jwt/src/Key.php Normal file
View File

@@ -0,0 +1,54 @@
<?php
namespace Firebase\JWT;
use InvalidArgumentException;
use OpenSSLAsymmetricKey;
use OpenSSLCertificate;
use TypeError;
class Key
{
/**
* @param string|OpenSSLAsymmetricKey|OpenSSLCertificate $keyMaterial
* @param string $algorithm
*/
public function __construct(
#[\SensitiveParameter] private $keyMaterial,
private string $algorithm
) {
if (
!\is_string($keyMaterial)
&& !$keyMaterial instanceof OpenSSLAsymmetricKey
&& !$keyMaterial instanceof OpenSSLCertificate
) {
throw new TypeError('Key material must be a string, OpenSSLCertificate, or OpenSSLAsymmetricKey');
}
if (empty($keyMaterial)) {
throw new InvalidArgumentException('Key material must not be empty');
}
if (empty($algorithm)) {
throw new InvalidArgumentException('Algorithm must not be empty');
}
}
/**
* Return the algorithm valid for this key
*
* @return string
*/
public function getAlgorithm(): string
{
return $this->algorithm;
}
/**
* @return string|OpenSSLAsymmetricKey|OpenSSLCertificate
*/
public function getKeyMaterial()
{
return $this->keyMaterial;
}
}

View File

@@ -0,0 +1,7 @@
<?php
namespace Firebase\JWT;
class SignatureInvalidException extends \UnexpectedValueException
{
}