Fix FLEXIAPI-464 Add per Space custom RFC8898 flow

This commit is contained in:
Timothée Jaussoin 2026-04-30 14:09:10 +00:00
parent 8585095d99
commit 0669b0d965
16 changed files with 723 additions and 105 deletions

View file

@ -8,12 +8,13 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/).
### Added ### Added
- **Add CardDav servers** They can be configured in the administration panels and the API. - **CardDav servers** They can be configured in the administration panels and the API.
- **Rockylinux 10 support** Packages are now available in the official repository - **Rockylinux 10 support** Packages are now available in the official repository
- **Artisan cleanup script for statistics** Add an artisan console script to clear statistics after n days `app:clear-statistics {days} {--apply}` - **Artisan cleanup script for statistics** Add an artisan console script to clear statistics after n days `app:clear-statistics {days} {--apply}`
- **Add Voicemail features and related API endpoints** to integrate with `flexisip-voicemail` - **Voicemail features and related API endpoints** to integrate with `flexisip-voicemail`
- **Add Call Forwarding features and related API endpoints** - **Call Forwarding features and related API endpoints**
- **Add generated licenses.md file in FlexiAPI** - **Generated licenses.md file in FlexiAPI**
- **Per Space Custom SSO authentication flow**
### Changed ### Changed
@ -21,6 +22,13 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/).
- **PHP 8.2 minimum** Laravel and its dependencies were upgraded to version 11 as well. - **PHP 8.2 minimum** Laravel and its dependencies were upgraded to version 11 as well.
- **Logout the user when the password is correctly changed** - **Logout the user when the password is correctly changed**
### Removed
- **Space SSO authentication:** Custom SSO authentication support has now be moved into Spaces configuraton, the following environnement keys are removed.
- `JWT_RSA_PUBLIC_KEY_PEM`
- `JWT_SIP_IDENTIFIER`
- `ACCOUNT_AUTHENTICATION_BEARER`, it is now generated directly from the Space configuration
## [2.0] ## [2.0]
### Added ### Added

View file

@ -29,7 +29,6 @@ ACCOUNT_EMAIL_UNIQUE=false # Emails are unique between all the accounts
ACCOUNT_BLACKLISTED_USERNAMES= ACCOUNT_BLACKLISTED_USERNAMES=
ACCOUNT_USERNAME_REGEX="^[a-z0-9+_.-]*$" ACCOUNT_USERNAME_REGEX="^[a-z0-9+_.-]*$"
ACCOUNT_DEFAULT_PASSWORD_ALGORITHM=SHA-256 # Can ONLY be MD5 or SHA-256 in capital, default to SHA-256 ACCOUNT_DEFAULT_PASSWORD_ALGORITHM=SHA-256 # Can ONLY be MD5 or SHA-256 in capital, default to SHA-256
ACCOUNT_AUTHENTICATION_BEARER= # Bearer value (WWW-Authenticate: Bearer <value>) of the external service that can provide a trusted (eg. JWT token) for the authentication, takes priority and disable the DIGEST auth if set, see https://www.rfc-editor.org/rfc/rfc8898
# Blocking service # Blocking service
BLOCKING_TIME_PERIOD_CHECK=30 # Time span on which the blocking service will proceed, in minutes BLOCKING_TIME_PERIOD_CHECK=30 # Time span on which the blocking service will proceed, in minutes
@ -93,9 +92,5 @@ OVH_APP_SENDER=
HCAPTCHA_SECRET=secret-key HCAPTCHA_SECRET=secret-key
HCAPTCHA_SITEKEY=site-key HCAPTCHA_SITEKEY=site-key
# JWT
JWT_RSA_PUBLIC_KEY_PEM=
JWT_SIP_IDENTIFIER=
# Temporary toggles # Temporary toggles
APP_SHOW_LOGIN_COUNTER_TEMP= # default true APP_SHOW_LOGIN_COUNTER_TEMP= # default true

View file

@ -0,0 +1,56 @@
<?php
namespace App\Http\Controllers\Admin\Space;
use App\Space;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
class SSOServerController extends Controller
{
public function show(int $spaceId)
{
return view('admin.space.sso_server.show', [
'space' => Space::findOrFail($spaceId)
]);
}
public function refreshPublicKey(int $spaceId)
{
$space = Space::findOrFail($spaceId);
if (!$space->refreshSSOCertificate()) {
return redirect()->back()->withErrors([
'sso_public_key' => __('The public key cannot be refreshed')
]);
}
$space->save();
return redirect()->back();
}
public function store(Request $request, int $spaceId)
{
$request->validate([
'sso_server_url' => 'required|url|ends_with:/',
'sso_realm' => 'required',
'sso_sip_identifier' => 'required'
]);
$space = Space::findOrFail($spaceId);
$space->sso_server_url = $request->get('sso_server_url');
$space->sso_realm = $request->get('sso_realm');
$space->sso_sip_identifier = $request->get('sso_sip_identifier');
if ($space->refreshSSOCertificate()) {
$space->save();
} else {
return redirect()->back()->withErrors([
'sso_public_key' => __('The public key cannot be refreshed')
]);
}
return redirect()->route('admin.spaces.integration', $spaceId);
}
}

View file

@ -20,6 +20,7 @@
namespace App\Http\Middleware; namespace App\Http\Middleware;
use App\Account; use App\Account;
use App\Space;
use Closure; use Closure;
use DateTimeImmutable; use DateTimeImmutable;
use Illuminate\Http\Request; use Illuminate\Http\Request;
@ -39,13 +40,18 @@ class AuthenticateJWT
{ {
public function handle(Request $request, Closure $next) public function handle(Request $request, Closure $next)
{ {
if ($request->bearerToken() && config('services.jwt.rsa_public_key_pem')) { if ($request->bearerToken() && $request->space?->sso_public_key) {
if (!extension_loaded('sodium')) { if (!extension_loaded('sodium')) {
abort(403, "PHP Sodium extension isn't loaded"); abort(403, "PHP Sodium extension isn't loaded");
} }
$publicKey = InMemory::plainText(config('services.jwt.rsa_public_key_pem')); $publicKey = InMemory::plainText($request->space->sso_public_key);
try {
$token = (new Parser(new JoseEncoder()))->parse($request->bearerToken()); $token = (new Parser(new JoseEncoder()))->parse($request->bearerToken());
} catch (\Throwable $th) {
return $this->generateUnauthorizedBearerResponse($request->space, 'invalid_token', 'Invalid bearer ' . $th->getMessage());
}
$signer = null; $signer = null;
@ -64,19 +70,19 @@ class AuthenticateJWT
} }
if ($signer == null) { if ($signer == null) {
return $this->generateUnauthorizedBearerResponse('invalid_token', 'Unsupported RSA signature'); return $this->generateUnauthorizedBearerResponse($request->space, 'invalid_token', 'Unsupported RSA signature');
} }
if (!(new Validator())->validate($token, new SignedWith($signer, $publicKey))) { if (!(new Validator())->validate($token, new SignedWith($signer, $publicKey))) {
return $this->generateUnauthorizedBearerResponse('invalid_token', 'Invalid JWT token signature'); return $this->generateUnauthorizedBearerResponse($request->space, 'invalid_token', 'Invalid JWT token signature');
} }
if ($token->isExpired(new DateTimeImmutable())) { if ($token->isExpired(new DateTimeImmutable())) {
return $this->generateUnauthorizedBearerResponse('invalid_token', 'Expired JWT token'); return $this->generateUnauthorizedBearerResponse($request->space, 'invalid_token', 'Expired JWT token');
} }
$account = null; $account = null;
$identifierKey = config('services.jwt.sip_identifier'); $identifierKey = $request->space->sso_sip_identifier;
if ($identifierKey == '') $identifierKey = 'sip_identity'; if ($identifierKey == '') $identifierKey = 'sip_identity';
if ($token->claims()->has($identifierKey)) { if ($token->claims()->has($identifierKey)) {
@ -101,7 +107,7 @@ class AuthenticateJWT
return $next($request); return $next($request);
} }
if (!empty(config('app.account_authentication_bearer')) if ($request->space?->sso_authentication_bearer
// Bypass the JWT auth if we have an API Key // Bypass the JWT auth if we have an API Key
&& !$request->header('x-api-key') && !$request->header('x-api-key')
&& !$request->cookie('x-api-key') && !$request->cookie('x-api-key')
@ -110,7 +116,7 @@ class AuthenticateJWT
$response->header( $response->header(
'WWW-Authenticate', 'WWW-Authenticate',
'Bearer ' . config('app.account_authentication_bearer') 'Bearer ' . $request->space?->sso_authentication_bearer
); );
$response->setStatusCode(401); $response->setStatusCode(401);
@ -121,10 +127,10 @@ class AuthenticateJWT
return $next($request); return $next($request);
} }
private function generateUnauthorizedBearerResponse(string $error, string $description): Response private function generateUnauthorizedBearerResponse(Space $space, string $error, string $description): Response
{ {
$bearer = 'Bearer ' . config('app.account_authentication_bearer'); $bearer = 'Bearer ' . $space->sso_authentication_bearer;
$bearer .= !empty(config('app.account_authentication_bearer')) $bearer .= $space->sso_authentication_bearer != null
? ', ' ? ', '
: ''; : '';

View file

@ -22,13 +22,16 @@ use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Http;
use Carbon\Carbon; use Carbon\Carbon;
use CoderCat\JWKToPEM\JWKConverter;
class Space extends Model class Space extends Model
{ {
use HasFactory; use HasFactory;
protected $with = ['emailServer', 'carddavServers']; protected $with = ['emailServer', 'carddavServers'];
protected $fillable = ['host', 'sso_public_key', 'sso_server_url', 'sso_realm'];
public const FORBIDDEN_KEYS = [ public const FORBIDDEN_KEYS = [
'account_proxy_registrar_address', 'account_proxy_registrar_address',
@ -66,13 +69,18 @@ class Space extends Model
'super' => 'boolean', 'super' => 'boolean',
]; ];
protected $attributes = [
'sso_sip_identifier' => 'sip_identity'
];
public const HOST_REGEX = '[\w\-]+'; public const HOST_REGEX = '[\w\-]+';
public const DOMAIN_REGEX = '(?=^.{4,253}$)(^((?!-)[a-z0-9-]{1,63}(?<!-)\.)+[a-z]{2,63}$)'; public const DOMAIN_REGEX = '(?=^.{4,253}$)(^((?!-)[a-z0-9-]{1,63}(?<!-)\.)+[a-z]{2,63}$)';
protected static function booted() protected static function booted()
{ {
static::addGlobalScope('domain', function (Builder $builder) { static::addGlobalScope('domain', function (Builder $builder) {
if (!Auth::hasUser()) return; if (!Auth::hasUser())
return;
if (Auth::hasUser() || Auth::user()->superAdmin) { if (Auth::hasUser() || Auth::user()->superAdmin) {
return; return;
@ -116,7 +124,7 @@ class Space extends Model
public function getAccountsPercentageAttribute(): int public function getAccountsPercentageAttribute(): int
{ {
if ($this->max_accounts != null) { if ($this->max_accounts != null) {
return (int)($this->accounts()->count() / $this->max_accounts * 100); return (int) ($this->accounts()->count() / $this->max_accounts * 100);
} }
return Command::SUCCESS; return Command::SUCCESS;
@ -137,6 +145,37 @@ class Space extends Model
return $this->host == config('app.root_host'); return $this->host == config('app.root_host');
} }
public function refreshSSOCertificate(): bool
{
if (isset($this->attributes['sso_server_url']) && isset($this->attributes['sso_realm'])) {
$response = Http::get($this->attributes['sso_server_url'] . '/realms/' . $this->attributes['sso_realm'] . '/protocol/openid-connect/certs');
$jwkConverter = new JWKConverter();
if ($response->status() == '200' && $publicKey = $response->json('keys')[0]) {
$this->attributes['sso_public_key'] = $jwkConverter->toPEM($publicKey);
$this->attributes['updated_at'] = Carbon::now();
return true;
}
}
return false;
}
/**
* Non standard authentication flow based on RFC 8898
*/
public function getSSOAuthenticationBearerAttribute(): ?string
{
if (isset($this->attributes['sso_server_url']) && isset($this->attributes['sso_realm'])) {
return
'authz_server="' . $this->attributes['sso_server_url'] . 'realms/' . $this->attributes['sso_realm'] . '"' .
',realm="' . $this->attributes['sso_realm'] . '"';
}
return null;
}
public function getAccountsPercentageClassAttribute(): string public function getAccountsPercentageClassAttribute(): string
{ {
if ($this->getAccountsPercentageAttribute() >= 80) { if ($this->getAccountsPercentageAttribute() >= 80) {
@ -153,7 +192,7 @@ class Space extends Model
public function getDaysLeftAttribute(): ?int public function getDaysLeftAttribute(): ?int
{ {
if ($this->expire_at != null) { if ($this->expire_at != null) {
return (int)$this->expire_at->diffInDays(Carbon::now()) + 1; return (int) $this->expire_at->diffInDays(Carbon::now()) + 1;
} }
return null; return null;

View file

@ -10,6 +10,7 @@
"require": { "require": {
"php": ">=8.2", "php": ">=8.2",
"awobaz/compoships": "^2.4.1", "awobaz/compoships": "^2.4.1",
"codercat/jwk-to-pem": "^1.1",
"comcast/php-legal-licenses": "^2.2", "comcast/php-legal-licenses": "^2.2",
"doctrine/dbal": "^3.10.1", "doctrine/dbal": "^3.10.1",
"endroid/qr-code": "^5.1", "endroid/qr-code": "^5.1",

275
flexiapi/composer.lock generated
View file

@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically" "This file is @generated automatically"
], ],
"content-hash": "3e094d7231e491736d1b2cb70db958de", "content-hash": "57ecd41850aee08a3926f70211252257",
"packages": [ "packages": [
{ {
"name": "awobaz/compoships", "name": "awobaz/compoships",
@ -252,6 +252,50 @@
], ],
"time": "2023-12-11T17:09:12+00:00" "time": "2023-12-11T17:09:12+00:00"
}, },
{
"name": "codercat/jwk-to-pem",
"version": "1.1",
"source": {
"type": "git",
"url": "https://github.com/acodercat/php-jwk-to-pem.git",
"reference": "4b3cdcf5f87b9b074f132f763a6b7b82c7d3ff1d"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/acodercat/php-jwk-to-pem/zipball/4b3cdcf5f87b9b074f132f763a6b7b82c7d3ff1d",
"reference": "4b3cdcf5f87b9b074f132f763a6b7b82c7d3ff1d",
"shasum": ""
},
"require": {
"php": ">=7.1",
"phpseclib/phpseclib": "^3.0"
},
"require-dev": {
"phpunit/phpunit": "^7.0"
},
"type": "library",
"autoload": {
"psr-4": {
"CoderCat\\JWKToPEM\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "codercat",
"email": "1067302838@qq.com"
}
],
"description": "Convert JWK to PEM format.",
"support": {
"issues": "https://github.com/acodercat/php-jwk-to-pem/issues",
"source": "https://github.com/acodercat/php-jwk-to-pem/tree/1.1"
},
"time": "2021-04-28T07:37:03+00:00"
},
{ {
"name": "comcast/php-legal-licenses", "name": "comcast/php-legal-licenses",
"version": "v2.2.0", "version": "v2.2.0",
@ -4128,6 +4172,125 @@
}, },
"time": "2025-01-02T16:09:40+00:00" "time": "2025-01-02T16:09:40+00:00"
}, },
{
"name": "paragonie/constant_time_encoding",
"version": "v3.1.3",
"source": {
"type": "git",
"url": "https://github.com/paragonie/constant_time_encoding.git",
"reference": "d5b01a39b3415c2cd581d3bd3a3575c1ebbd8e77"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/paragonie/constant_time_encoding/zipball/d5b01a39b3415c2cd581d3bd3a3575c1ebbd8e77",
"reference": "d5b01a39b3415c2cd581d3bd3a3575c1ebbd8e77",
"shasum": ""
},
"require": {
"php": "^8"
},
"require-dev": {
"infection/infection": "^0",
"nikic/php-fuzzer": "^0",
"phpunit/phpunit": "^9|^10|^11",
"vimeo/psalm": "^4|^5|^6"
},
"type": "library",
"autoload": {
"psr-4": {
"ParagonIE\\ConstantTime\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Paragon Initiative Enterprises",
"email": "security@paragonie.com",
"homepage": "https://paragonie.com",
"role": "Maintainer"
},
{
"name": "Steve 'Sc00bz' Thomas",
"email": "steve@tobtu.com",
"homepage": "https://www.tobtu.com",
"role": "Original Developer"
}
],
"description": "Constant-time Implementations of RFC 4648 Encoding (Base-64, Base-32, Base-16)",
"keywords": [
"base16",
"base32",
"base32_decode",
"base32_encode",
"base64",
"base64_decode",
"base64_encode",
"bin2hex",
"encoding",
"hex",
"hex2bin",
"rfc4648"
],
"support": {
"email": "info@paragonie.com",
"issues": "https://github.com/paragonie/constant_time_encoding/issues",
"source": "https://github.com/paragonie/constant_time_encoding"
},
"time": "2025-09-24T15:06:41+00:00"
},
{
"name": "paragonie/random_compat",
"version": "v9.99.100",
"source": {
"type": "git",
"url": "https://github.com/paragonie/random_compat.git",
"reference": "996434e5492cb4c3edcb9168db6fbb1359ef965a"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/paragonie/random_compat/zipball/996434e5492cb4c3edcb9168db6fbb1359ef965a",
"reference": "996434e5492cb4c3edcb9168db6fbb1359ef965a",
"shasum": ""
},
"require": {
"php": ">= 7"
},
"require-dev": {
"phpunit/phpunit": "4.*|5.*",
"vimeo/psalm": "^1"
},
"suggest": {
"ext-libsodium": "Provides a modern crypto API that can be used to generate random bytes."
},
"type": "library",
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Paragon Initiative Enterprises",
"email": "security@paragonie.com",
"homepage": "https://paragonie.com"
}
],
"description": "PHP 5.x polyfill for random_bytes() and random_int() from PHP 7",
"keywords": [
"csprng",
"polyfill",
"pseudorandom",
"random"
],
"support": {
"email": "info@paragonie.com",
"issues": "https://github.com/paragonie/random_compat/issues",
"source": "https://github.com/paragonie/random_compat"
},
"time": "2020-10-15T08:29:30+00:00"
},
{ {
"name": "parsedown/laravel", "name": "parsedown/laravel",
"version": "1.2.1", "version": "1.2.1",
@ -4383,6 +4546,116 @@
], ],
"time": "2025-12-27T19:41:33+00:00" "time": "2025-12-27T19:41:33+00:00"
}, },
{
"name": "phpseclib/phpseclib",
"version": "3.0.52",
"source": {
"type": "git",
"url": "https://github.com/phpseclib/phpseclib.git",
"reference": "2adaefc83df2ec548558307690f376dd7d4f4fce"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/phpseclib/phpseclib/zipball/2adaefc83df2ec548558307690f376dd7d4f4fce",
"reference": "2adaefc83df2ec548558307690f376dd7d4f4fce",
"shasum": ""
},
"require": {
"paragonie/constant_time_encoding": "^1|^2|^3",
"paragonie/random_compat": "^1.4|^2.0|^9.99.99",
"php": ">=5.6.1"
},
"require-dev": {
"phpunit/phpunit": "*"
},
"suggest": {
"ext-dom": "Install the DOM extension to load XML formatted public keys.",
"ext-gmp": "Install the GMP (GNU Multiple Precision) extension in order to speed up arbitrary precision integer arithmetic operations.",
"ext-libsodium": "SSH2/SFTP can make use of some algorithms provided by the libsodium-php extension.",
"ext-mcrypt": "Install the Mcrypt extension in order to speed up a few other cryptographic operations.",
"ext-openssl": "Install the OpenSSL extension in order to speed up a wide variety of cryptographic operations."
},
"type": "library",
"autoload": {
"files": [
"phpseclib/bootstrap.php"
],
"psr-4": {
"phpseclib3\\": "phpseclib/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Jim Wigginton",
"email": "terrafrost@php.net",
"role": "Lead Developer"
},
{
"name": "Patrick Monnerat",
"email": "pm@datasphere.ch",
"role": "Developer"
},
{
"name": "Andreas Fischer",
"email": "bantu@phpbb.com",
"role": "Developer"
},
{
"name": "Hans-Jürgen Petrich",
"email": "petrich@tronic-media.com",
"role": "Developer"
},
{
"name": "Graham Campbell",
"email": "graham@alt-three.com",
"role": "Developer"
}
],
"description": "PHP Secure Communications Library - Pure-PHP implementations of RSA, AES, SSH2, SFTP, X.509 etc.",
"homepage": "http://phpseclib.sourceforge.net",
"keywords": [
"BigInteger",
"aes",
"asn.1",
"asn1",
"blowfish",
"crypto",
"cryptography",
"encryption",
"rsa",
"security",
"sftp",
"signature",
"signing",
"ssh",
"twofish",
"x.509",
"x509"
],
"support": {
"issues": "https://github.com/phpseclib/phpseclib/issues",
"source": "https://github.com/phpseclib/phpseclib/tree/3.0.52"
},
"funding": [
{
"url": "https://github.com/terrafrost",
"type": "github"
},
{
"url": "https://www.patreon.com/phpseclib",
"type": "patreon"
},
{
"url": "https://tidelift.com/funding/github/packagist/phpseclib/phpseclib",
"type": "tidelift"
}
],
"time": "2026-04-27T07:02:15+00:00"
},
{ {
"name": "phpunit/php-code-coverage", "name": "phpunit/php-code-coverage",
"version": "10.1.16", "version": "10.1.16",

View file

@ -24,7 +24,6 @@ return [
'account_email_unique' => env('ACCOUNT_EMAIL_UNIQUE', false), 'account_email_unique' => env('ACCOUNT_EMAIL_UNIQUE', false),
'account_username_regex' => env('ACCOUNT_USERNAME_REGEX', '^[a-z0-9+_.-]*$'), 'account_username_regex' => env('ACCOUNT_USERNAME_REGEX', '^[a-z0-9+_.-]*$'),
'account_default_password_algorithm' => env('ACCOUNT_DEFAULT_PASSWORD_ALGORITHM', 'SHA-256'), 'account_default_password_algorithm' => env('ACCOUNT_DEFAULT_PASSWORD_ALGORITHM', 'SHA-256'),
'account_authentication_bearer' => env('ACCOUNT_AUTHENTICATION_BEARER', null),
/** /**
* Time limit before the API Key and related cookie are expired * Time limit before the API Key and related cookie are expired

View file

@ -29,10 +29,4 @@ return [
'secret' => env('AWS_SECRET_ACCESS_KEY'), 'secret' => env('AWS_SECRET_ACCESS_KEY'),
'region' => env('AWS_DEFAULT_REGION', 'us-east-1'), 'region' => env('AWS_DEFAULT_REGION', 'us-east-1'),
], ],
'jwt' => [
'rsa_public_key_pem' => env('JWT_RSA_PUBLIC_KEY_PEM'),
'sip_identifier' => env('JWT_SIP_IDENTIFIER', 'sip_identity'),
],
]; ];

View file

@ -0,0 +1,28 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('spaces', function (Blueprint $table) {
$table->string('sso_server_url')->nullable();
$table->string('sso_realm')->nullable();
$table->string('sso_sip_identifier')->default('sip_identity');
$table->text('sso_public_key')->nullable();
});
}
public function down(): void
{
Schema::table('spaces', function (Blueprint $table) {
$table->dropColumn('sso_server_url');
$table->dropColumn('sso_realm');
$table->dropColumn('sso_sip_identifier');
$table->dropColumn('sso_public_key');
});
}
};

View file

@ -140,8 +140,11 @@
"Integration": "Intégration", "Integration": "Intégration",
"Intercom features": "Fonctionnalités d'interphonie", "Intercom features": "Fonctionnalités d'interphonie",
"It might actually disable this page, be careful": "Cette page pourrait être désactivée, faites attention", "It might actually disable this page, be careful": "Cette page pourrait être désactivée, faites attention",
"JWT key containing the user's SIP identity. sip_identity by default.": "Clef du JWT contenant l'identité SIP de l'utilisateur. sip_identity par défaut.",
"Key": "Clef", "Key": "Clef",
"SSO Server": "Serveur SSO",
"Last used": "Dernière utilisation", "Last used": "Dernière utilisation",
"Last update": "Dernière mise à jour",
"Leave empty to create a root Space.": "Laisser vide si vous souhaitez créer un Espace à la racine", "Leave empty to create a root Space.": "Laisser vide si vous souhaitez créer un Espace à la racine",
"Limit the number of results": "Limiter le nomber de résultats", "Limit the number of results": "Limiter le nomber de résultats",
"List of vcard fields to match for SIP domain": "Liste des champs vcard à matcher pour le domaine SIP", "List of vcard fields to match for SIP domain": "Liste des champs vcard à matcher pour le domaine SIP",
@ -192,10 +195,12 @@
"Provisioning": "Déploiement", "Provisioning": "Déploiement",
"Proxy/registrar address":"Adresse Proxy/registrar", "Proxy/registrar address":"Adresse Proxy/registrar",
"Public registration": "Inscription publiques", "Public registration": "Inscription publiques",
"Public key": "Clef publique",
"QR Code scanning": "Scan de QR Code", "QR Code scanning": "Scan de QR Code",
"Realm": "Royaume", "Realm": "Royaume",
"Recover your account using your email": "Récupérer votre compte avec votre email", "Recover your account using your email": "Récupérer votre compte avec votre email",
"Recorded at": "Enregistré le", "Recorded at": "Enregistré le",
"Refresh": "Rafraichir",
"Register": "Inscription", "Register": "Inscription",
"Registrar": "Registrar", "Registrar": "Registrar",
"Registration confirmed": "Confirmation de l'inscription", "Registration confirmed": "Confirmation de l'inscription",
@ -222,10 +227,12 @@
"Send an email to the user with provisioning information": "Envoyer un email à l'utilisateur avec les informations de déploiement", "Send an email to the user with provisioning information": "Envoyer un email à l'utilisateur avec les informations de déploiement",
"Send": "Envoyer", "Send": "Envoyer",
"Separated by commas": "Séparé par des virgules", "Separated by commas": "Séparé par des virgules",
"Server URL": "URL du serveur",
"Settings": "Paramètres", "Settings": "Paramètres",
"Show usernames only": "Afficher uniquement les noms d'utilisateur", "Show usernames only": "Afficher uniquement les noms d'utilisateur",
"SIP Adress": "Adresse SIP", "SIP Adress": "Adresse SIP",
"SIP Domain": "Domaine SIP", "SIP Domain": "Domaine SIP",
"SIP Identifier": "Identifiant SIP",
"Space": "Espace", "Space": "Espace",
"Spaces": "Espaces", "Spaces": "Espaces",
"Statistics": "Statistiques", "Statistics": "Statistiques",
@ -244,6 +251,7 @@
"The first line contains the labels": "La premières ligne contient les étiquettes", "The first line contains the labels": "La premières ligne contient les étiquettes",
"The following email address wants to register to the mailing list:":"Ladresse e-mail suivante souhaite sinscrire à la liste de diffusion :", "The following email address wants to register to the mailing list:":"Ladresse e-mail suivante souhaite sinscrire à la liste de diffusion :",
"The link can only be visited once": "Le lien ne peut être utilisé qu'une fois", "The link can only be visited once": "Le lien ne peut être utilisé qu'une fois",
"The public key cannot be refreshed": "La clef publique ne peut être rafraichie",
"Third-party SIP account": "Compte SIP tiers", "Third-party SIP account": "Compte SIP tiers",
"This code is valid for :minutes minutes.": "Ce code est valable pendant :minutes minutes.", "This code is valid for :minutes minutes.": "Ce code est valable pendant :minutes minutes.",
"This link is not available anymore.": "Ce lien n'est plus disponible.", "This link is not available anymore.": "Ce lien n'est plus disponible.",

View file

@ -112,6 +112,33 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE. SOFTWARE.
### codercat/jwk-to-pem (Version 1.1 | 4b3cdcf)
Convert JWK to PEM format.
Homepage: Not configured.
Licenses Used: MIT
MIT License
Copyright (c) 2018 codercat
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
### comcast/php-legal-licenses (Version v2.2.0 | 2b01ea1) ### comcast/php-legal-licenses (Version v2.2.0 | 2b01ea1)
A utility to generate a Licenses file containing the full license text for every dependency in your project for legal purposes. A utility to generate a Licenses file containing the full license text for every dependency in your project for legal purposes.
Homepage: https://github.com/Comcast/php-legal-licenses Homepage: https://github.com/Comcast/php-legal-licenses
@ -2028,6 +2055,88 @@ SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
### paragonie/constant_time_encoding (Version v3.1.3 | d5b01a3)
Constant-time Implementations of RFC 4648 Encoding (Base-64, Base-32, Base-16)
Homepage: Not configured.
Licenses Used: MIT
The MIT License (MIT)
Copyright (c) 2016 - 2022 Paragon Initiative Enterprises
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
------------------------------------------------------------------------------
This library was based on the work of Steve "Sc00bz" Thomas.
------------------------------------------------------------------------------
The MIT License (MIT)
Copyright (c) 2014 Steve Thomas
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
### paragonie/random_compat (Version v9.99.100 | 996434e)
PHP 5.x polyfill for random_bytes() and random_int() from PHP 7
Homepage: Not configured.
Licenses Used: MIT
The MIT License (MIT)
Copyright (c) 2015 Paragon Initiative Enterprises
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
### parsedown/laravel (Version 1.2.1 | c713ffe) ### parsedown/laravel (Version 1.2.1 | c713ffe)
Official Parsedown's Laravel Wrapper. Official Parsedown's Laravel Wrapper.
Homepage: http://parsedown.org Homepage: http://parsedown.org
@ -2333,6 +2442,31 @@ Licenses Used: Apache-2.0
See the License for the specific language governing permissions and See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
### phpseclib/phpseclib (Version 3.0.52 | 2adaefc)
PHP Secure Communications Library - Pure-PHP implementations of RSA, AES, SSH2, SFTP, X.509 etc.
Homepage: http://phpseclib.sourceforge.net
Licenses Used: MIT
Copyright (c) 2011-2019 TerraFrost and other contributors
Permission is hereby granted, free of charge, to any person obtaining
a copy of this software and associated documentation files (the
"Software"), to deal in the Software without restriction, including
without limitation the rights to use, copy, modify, merge, publish,
distribute, sublicense, and/or sell copies of the Software, and to
permit persons to whom the Software is furnished to do so, subject to
the following conditions:
The above copyright notice and this permission notice shall be
included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
### phpunit/php-code-coverage (Version 10.1.16 | 7e30826) ### phpunit/php-code-coverage (Version 10.1.16 | 7e30826)
Library that provides collection, processing, and rendering functionality for PHP code coverage information. Library that provides collection, processing, and rendering functionality for PHP code coverage information.
Homepage: https://github.com/sebastianbergmann/php-code-coverage Homepage: https://github.com/sebastianbergmann/php-code-coverage

View file

@ -10,17 +10,28 @@
<div class="grid third"> <div class="grid third">
<div class="card"> <div class="card">
@if ($space->emailServer)
<a class="btn small oppose" href="{{ route('admin.spaces.email.show', $space) }}">{{ __('Edit') }}</a>
<a class="btn small oppose tertiary" href="{{ route('admin.spaces.email.delete', $space) }}">{{ __('Delete') }}</a>
@else
<a class="btn small oppose secondary" href="{{ route('admin.spaces.email.show', $space) }}">{{ __('Configure') }}</a>
@endif
<span class="icon"><i class="ph ph-envelope"></i></span> <span class="icon"><i class="ph ph-envelope"></i></span>
<h3>{{ __('Email Server') }}</h3> <h3>{{ __('Email Server') }}</h3>
<p> <p>
@if ($space->emailServer) @if ($space->emailServer)
{{ $space->emailServer->host}}<br /><br /> {{ $space->emailServer->host}}<br /><br />
@endif @endif
@if ($space->emailServer) </p>
<a class="btn oppose" href="{{ route('admin.spaces.email.show', $space) }}">{{ __('Edit') }}</a> </div>
<a class="btn oppose tertiary" href="{{ route('admin.spaces.email.delete', $space) }}">{{ __('Delete') }}</a>
@else <div class="card">
<a class="btn oppose secondary" href="{{ route('admin.spaces.email.show', $space) }}">{{ __('Configure') }}</a> <a class="btn small oppose secondary" href="{{ route('admin.spaces.keycloak.show', $space) }}">{{ __('Configure') }}</a>
<span class="icon"><i class="ph ph-key"></i></span>
<h3>{{ __('SSO Server') }}</h3>
<p>
@if ($space->sso_server_url)
<code>{{ $space->sso_server_url}}</code><br /><br />
@endif @endif
</p> </p>
</div> </div>
@ -38,14 +49,13 @@
<div class="grid third"> <div class="grid third">
@foreach ($space->carddavServers as $carddavServer) @foreach ($space->carddavServers as $carddavServer)
<div class="card"> <div class="card">
<small class="oppose"><i class="ph ph-users"></i> {{ $carddavServer->accounts()->count() }}</small> <a class="btn small oppose" href="{{ route('admin.spaces.carddavs.edit', [$space, $carddavServer]) }}">{{ __('Edit') }}</a>
<a class="btn small oppose tertiary" href="{{ route('admin.spaces.carddavs.delete', [$space, $carddavServer]) }}">{{ __('Delete') }}</a>
<span class="icon"><i class="ph ph-identification-card"></i></span> <span class="icon"><i class="ph ph-identification-card"></i></span>
<h3>{{ $carddavServer->name }}</h3> <h3>{{ $carddavServer->name }}</h3>
<p> <p>
<small class="oppose"><i class="ph ph-users"></i> {{ $carddavServer->accounts()->count() }}</small>
{{ $carddavServer->uri}}<br /> {{ $carddavServer->uri}}<br />
<br />
<a class="btn oppose" href="{{ route('admin.spaces.carddavs.edit', [$space, $carddavServer]) }}">{{ __('Edit') }}</a>
<a class="btn oppose tertiary" href="{{ route('admin.spaces.carddavs.delete', [$space, $carddavServer]) }}">{{ __('Delete') }}</a>
</p> </p>
</div> </div>
@endforeach @endforeach

View file

@ -0,0 +1,57 @@
@extends('layouts.main')
@section('breadcrumb')
@include('admin.parts.breadcrumb.spaces.integration')
<li class="breadcrumb-item active" aria-current="page">{{ __('SSO Server') }}</li>
@endsection
@section('content')
<header>
<h1><i class="ph ph-key"></i> {{ $space->name }}</h1>
</header>
<form method="POST"
action="{{ route('admin.spaces.keycloak.store', $space->id) }}"
id="show" accept-charset="UTF-8">
@csrf
@method('post')
<div>
<input placeholder="https://keycloak.server.tld/" required="required" name="sso_server_url" type="url"
value="@if($space->id){{ $space->sso_server_url }}@else{{ old('sso_server_url') }}@endif">
<label for="sso_server_url">{{ __('Server URL') }}</label>
@include('parts.errors', ['name' => 'sso_server_url'])
</div>
<div>
<input placeholder="cogip" required="required" name="sso_realm" type="text"
value="@if($space->id){{ $space->sso_realm }}@else{{ old('sso_realm') }}@endif">
<label for="sso_realm">{{ __('Realm') }}</label>
@include('parts.errors', ['name' => 'sso_realm'])
</div>
<div>
<input placeholder="sip_identity" name="sso_sip_identifier" type="text" required="required"
value="@if($space->id && isset($space->sso_sip_identifier)){{ $space->sso_sip_identifier }}@else{{ old('sso_sip_identifier') }}@endif">
<label for="sso_sip_identifier">{{ __('SIP Identifier') }}</label>
@include('parts.errors', ['name' => 'sso_sip_identifier'])
<span class="supporting">{{ __("JWT key containing the user's SIP identity. sip_identity by default.")}}</span>
</div>
</form>
<br />
<hr />
@include('parts.errors', ['name' => 'sso_public_key'])
@if ($space->sso_public_key)
<h4>{{ __('Public key') }}</h4> <small>{{ __('Last update') }}: {{ $space->updated_at }}</small>
<br />
<pre style="display: inline-block;"><code>{{ $space->sso_public_key }}</code></pre>
<br />
<a class="btn small secondary" href="{{ route('admin.spaces.keycloak.refresh_public_key', $space) }}">{{ __('Refresh') }}</a>
<hr />
@endif
<input form="show" class="btn" type="submit" value="@if($space->id){{ __('Update') }}@else{{ __('Create') }}@endif">
@endsection

View file

@ -57,6 +57,7 @@ use App\Http\Controllers\Admin\Space\CardDavServerController;
use App\Http\Controllers\Admin\Space\ContactsListContactController; use App\Http\Controllers\Admin\Space\ContactsListContactController;
use App\Http\Controllers\Admin\Space\ContactsListController; use App\Http\Controllers\Admin\Space\ContactsListController;
use App\Http\Controllers\Admin\Space\EmailServerController; use App\Http\Controllers\Admin\Space\EmailServerController;
use App\Http\Controllers\Admin\Space\SSOServerController;
use App\Http\Controllers\Admin\SpaceController; use App\Http\Controllers\Admin\SpaceController;
use App\Http\Controllers\Admin\StatisticsController; use App\Http\Controllers\Admin\StatisticsController;
use Illuminate\Support\Facades\Route; use Illuminate\Support\Facades\Route;
@ -88,7 +89,6 @@ Route::name('file.')->prefix('f')->controller(FileController::class)->group(func
}); });
Route::group(['middleware' => ['auth.jwt', 'auth.digest_or_key']], function () { Route::group(['middleware' => ['auth.jwt', 'auth.digest_or_key']], function () {
Route::get('provisioning/me', [ProvisioningController::class, 'me'])->name('provisioning.me'); Route::get('provisioning/me', [ProvisioningController::class, 'me'])->name('provisioning.me');
// vCard 4.0 // vCard 4.0
@ -199,6 +199,11 @@ Route::middleware(['feature.web_panel_enabled'])->group(function () {
Route::get('delete', 'delete')->name('delete'); Route::get('delete', 'delete')->name('delete');
Route::delete('/', 'destroy')->name('destroy'); Route::delete('/', 'destroy')->name('destroy');
}); });
Route::name('keycloak.')->prefix('{space}/keycloak')->controller(SSOServerController::class)->group(function () {
Route::get('/', 'show')->name('show');
Route::get('refresh_public_key', 'refreshPublicKey')->name('refresh_public_key');
Route::post('/', 'store')->name('store');
});
Route::resource('{space}/carddavs', CardDavServerController::class, ['except' => ['index', 'show']]); Route::resource('{space}/carddavs', CardDavServerController::class, ['except' => ['index', 'show']]);
Route::get('{space}/carddavs/{carddav}/delete', [CardDavServerController::class, 'delete'])->name('carddavs.delete'); Route::get('{space}/carddavs/{carddav}/delete', [CardDavServerController::class, 'delete'])->name('carddavs.delete');

View file

@ -44,7 +44,7 @@ class AccountJWTAuthenticationTest extends TestCase
{ {
parent::setUp(); parent::setUp();
$keys = openssl_pkey_new(array("private_key_bits" => 4096,"private_key_type" => OPENSSL_KEYTYPE_RSA)); $keys = openssl_pkey_new(array("private_key_bits" => 4096, "private_key_type" => OPENSSL_KEYTYPE_RSA));
$this->serverPublicKeyPem = openssl_pkey_get_details($keys)['key']; $this->serverPublicKeyPem = openssl_pkey_get_details($keys)['key'];
openssl_pkey_export($keys, $this->serverPrivateKeyPem); openssl_pkey_export($keys, $this->serverPrivateKeyPem);
} }
@ -52,15 +52,20 @@ class AccountJWTAuthenticationTest extends TestCase
public function testBaseProvisioning() public function testBaseProvisioning()
{ {
# JWT is disabled if Sodium is not loaded # JWT is disabled if Sodium is not loaded
if (!extension_loaded('sodium')) return; if (!extension_loaded('sodium'))
return;
$password = Password::factory()->create(); $password = Password::factory()->create();
$domain = 'sip_provisioning.example.com'; $domain = 'sip_provisioning.example.com';
$bearer = 'authz_server="https://sso.test/", realm="sip.test.org"';
\App\Space::where('domain', $password->account->domain)->update(['host' => $domain]); $space = \App\Space::where('domain', $password->account->domain)->first();
$space->update([
'host' => $domain,
'sso_public_key' => $this->serverPublicKeyPem,
'sso_sso_server_url' => 'https://sso.test/',
'sso_realm' => 'sip.test.org'
]);
config()->set('app.sip_domain', $domain); config()->set('app.sip_domain', $domain);
config()->set('services.jwt.rsa_public_key_pem', $this->serverPublicKeyPem);
$this->get($this->route)->assertStatus(400); $this->get($this->route)->assertStatus(400);
@ -69,7 +74,7 @@ class AccountJWTAuthenticationTest extends TestCase
$token = (new JwtFacade(null, $clock))->issue( $token = (new JwtFacade(null, $clock))->issue(
new Sha256(), new Sha256(),
InMemory::plainText($this->serverPrivateKeyPem), InMemory::plainText($this->serverPrivateKeyPem),
static fn ( static fn(
Builder $builder, Builder $builder,
DateTimeImmutable $issuedAt DateTimeImmutable $issuedAt
): Builder => $builder->withClaim('email', $password->account->email) ): Builder => $builder->withClaim('email', $password->account->email)
@ -79,13 +84,10 @@ class AccountJWTAuthenticationTest extends TestCase
// SIP identifier // SIP identifier
// This line shoudn't be required, but the pipeline doesn't get the default value somehow
config()->set('services.jwt.sip_identifier', 'sip_identity');
$token = (new JwtFacade(null, $clock))->issue( $token = (new JwtFacade(null, $clock))->issue(
new Sha256(), new Sha256(),
InMemory::plainText($this->serverPrivateKeyPem), InMemory::plainText($this->serverPrivateKeyPem),
static fn ( static fn(
Builder $builder, Builder $builder,
DateTimeImmutable $issuedAt DateTimeImmutable $issuedAt
): Builder => $builder->withClaim('sip_identity', 'sip:' . $password->account->username . '@' . $password->account->domain) ): Builder => $builder->withClaim('sip_identity', 'sip:' . $password->account->username . '@' . $password->account->domain)
@ -93,13 +95,11 @@ class AccountJWTAuthenticationTest extends TestCase
$this->checkToken($token); $this->checkToken($token);
// Handle JWT_SIP_IDENTIFIER= // Handle empty sso_sip_identifier
config()->set('services.jwt.sip_identifier', '');
$token = (new JwtFacade(null, $clock))->issue( $token = (new JwtFacade(null, $clock))->issue(
new Sha256(), new Sha256(),
InMemory::plainText($this->serverPrivateKeyPem), InMemory::plainText($this->serverPrivateKeyPem),
static fn ( static fn(
Builder $builder, Builder $builder,
DateTimeImmutable $issuedAt DateTimeImmutable $issuedAt
): Builder => $builder->withClaim('sip_identity', 'sip:' . $password->account->username . '@' . $password->account->domain) ): Builder => $builder->withClaim('sip_identity', 'sip:' . $password->account->username . '@' . $password->account->domain)
@ -109,12 +109,12 @@ class AccountJWTAuthenticationTest extends TestCase
// Custom SIP identifier // Custom SIP identifier
$otherIdentifier = 'sip_other_identifier'; $otherIdentifier = 'sip_other_identifier';
config()->set('services.jwt.sip_identifier', $otherIdentifier); \App\Space::where('domain', $password->account->domain)->update(['sso_sip_identifier' => 'sip_other_identifier']);
$token = (new JwtFacade(null, $clock))->issue( $token = (new JwtFacade(null, $clock))->issue(
new Sha256(), new Sha256(),
InMemory::plainText($this->serverPrivateKeyPem), InMemory::plainText($this->serverPrivateKeyPem),
static fn ( static fn(
Builder $builder, Builder $builder,
DateTimeImmutable $issuedAt DateTimeImmutable $issuedAt
): Builder => $builder->withClaim($otherIdentifier, 'sip:' . $password->account->username . '@' . $password->account->domain) ): Builder => $builder->withClaim($otherIdentifier, 'sip:' . $password->account->username . '@' . $password->account->domain)
@ -126,7 +126,7 @@ class AccountJWTAuthenticationTest extends TestCase
$token = (new JwtFacade(null, $clock))->issue( $token = (new JwtFacade(null, $clock))->issue(
new Sha512(), new Sha512(),
InMemory::plainText($this->serverPrivateKeyPem), InMemory::plainText($this->serverPrivateKeyPem),
static fn ( static fn(
Builder $builder, Builder $builder,
DateTimeImmutable $issuedAt DateTimeImmutable $issuedAt
): Builder => $builder->withClaim('email', $password->account->email) ): Builder => $builder->withClaim('email', $password->account->email)
@ -140,7 +140,7 @@ class AccountJWTAuthenticationTest extends TestCase
$token = (new JwtFacade(null, $oldClock))->issue( $token = (new JwtFacade(null, $oldClock))->issue(
new Sha256(), new Sha256(),
InMemory::plainText($this->serverPrivateKeyPem), InMemory::plainText($this->serverPrivateKeyPem),
static fn ( static fn(
Builder $builder, Builder $builder,
DateTimeImmutable $issuedAt DateTimeImmutable $issuedAt
): Builder => $builder->withClaim('email', $password->account->email) ): Builder => $builder->withClaim('email', $password->account->email)
@ -156,8 +156,6 @@ class AccountJWTAuthenticationTest extends TestCase
$this->assertStringContainsString('invalid_token', $response->headers->get('WWW-Authenticate')); $this->assertStringContainsString('invalid_token', $response->headers->get('WWW-Authenticate'));
// ...with the bearer // ...with the bearer
config()->set('app.account_authentication_bearer', $bearer);
$response = $this->withHeaders([ $response = $this->withHeaders([
'Authorization' => 'Bearer ' . $token->toString(), 'Authorization' => 'Bearer ' . $token->toString(),
'x-linphone-provisioning' => true, 'x-linphone-provisioning' => true,
@ -165,14 +163,14 @@ class AccountJWTAuthenticationTest extends TestCase
->get($this->accountRoute) ->get($this->accountRoute)
->assertStatus(401); ->assertStatus(401);
$this->assertStringContainsString($bearer . ', ', $response->headers->get('WWW-Authenticate')); $this->assertStringContainsString($space->sso_authentication_bearer . ', ', $response->headers->get('WWW-Authenticate'));
$this->assertStringContainsString('invalid_token', $response->headers->get('WWW-Authenticate')); $this->assertStringContainsString('invalid_token', $response->headers->get('WWW-Authenticate'));
// Wrong email // Wrong email
$token = (new JwtFacade(null, $clock))->issue( $token = (new JwtFacade(null, $clock))->issue(
new Sha256(), new Sha256(),
InMemory::plainText($this->serverPrivateKeyPem), InMemory::plainText($this->serverPrivateKeyPem),
static fn ( static fn(
Builder $builder, Builder $builder,
DateTimeImmutable $issuedAt DateTimeImmutable $issuedAt
): Builder => $builder->withClaim('email', 'unknow@man.org') ): Builder => $builder->withClaim('email', 'unknow@man.org')
@ -186,13 +184,13 @@ class AccountJWTAuthenticationTest extends TestCase
->assertStatus(403); ->assertStatus(403);
// Wrong signature key // Wrong signature key
$keys = openssl_pkey_new(array("private_key_bits" => 4096,"private_key_type" => OPENSSL_KEYTYPE_RSA)); $keys = openssl_pkey_new(array("private_key_bits" => 4096, "private_key_type" => OPENSSL_KEYTYPE_RSA));
openssl_pkey_export($keys, $wrongServerPrivateKeyPem); openssl_pkey_export($keys, $wrongServerPrivateKeyPem);
$wrongToken = (new JwtFacade(null, $clock))->issue( $wrongToken = (new JwtFacade(null, $clock))->issue(
new Sha256(), new Sha256(),
InMemory::plainText($wrongServerPrivateKeyPem), InMemory::plainText($wrongServerPrivateKeyPem),
static fn ( static fn(
Builder $builder, Builder $builder,
DateTimeImmutable $issuedAt DateTimeImmutable $issuedAt
): Builder => $builder->withClaim('email', $password->account->email) ): Builder => $builder->withClaim('email', $password->account->email)
@ -208,16 +206,23 @@ class AccountJWTAuthenticationTest extends TestCase
public function testAuthBearerUrl() public function testAuthBearerUrl()
{ {
$value = 'authz_server="https://auth_bearer.com/" realm="realm"'; # JWT is disabled if Sodium is not loaded
config()->set('app.account_authentication_bearer', $value); if (!extension_loaded('sodium'))
return;
Password::factory()->create(); $password = Password::factory()->create();
$space = \App\Space::where('domain', $password->account->domain)->first();
$space->update([
'sso_public_key' => $this->serverPublicKeyPem,
'sso_server_url' => 'https://auth_bearer.com/',
'sso_realm' => 'realm'
]);
$response = $this->json($this->method, $this->routeAccountMe) $response = $this->json($this->method, $this->routeAccountMe)
->assertStatus(401); ->assertStatus(401);
$this->assertStringContainsString( $this->assertStringContainsString(
'Bearer ' . $value, 'Bearer ' . $space->sso_authentication_bearer,
$response->headers->all()['www-authenticate'][0] $response->headers->all()['www-authenticate'][0]
); );
@ -227,7 +232,7 @@ class AccountJWTAuthenticationTest extends TestCase
->assertStatus(401); ->assertStatus(401);
$this->assertStringContainsString( $this->assertStringContainsString(
'Bearer ' . $value, 'Bearer ' . $space->sso_authentication_bearer,
$response->headers->all()['www-authenticate'][0] $response->headers->all()['www-authenticate'][0]
); );
@ -239,7 +244,7 @@ class AccountJWTAuthenticationTest extends TestCase
->assertStatus(401); ->assertStatus(401);
$this->assertStringContainsString( $this->assertStringContainsString(
'Bearer ' . $value, 'Bearer ' . $space->sso_authentication_bearer,
$response->headers->all()['www-authenticate'][0] $response->headers->all()['www-authenticate'][0]
); );
} }