Added OpenID Connect.
This commit is contained in:
parent
24d93a5dbf
commit
aaba24894c
33 changed files with 1440 additions and 131 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
@ -16,6 +16,7 @@
|
|||
/config/github.cfg
|
||||
/config/config.ini
|
||||
/config/github.ini
|
||||
/config/keys/*.pem
|
||||
/.debug
|
||||
/.migrating
|
||||
|
||||
|
|
2
VERSION
2
VERSION
|
@ -1 +1 @@
|
|||
20250220
|
||||
20250225
|
||||
|
|
|
@ -77,22 +77,24 @@ const MszOAuth2Authorise = async () => {
|
|||
const translateError = (serverError, detail) => {
|
||||
if(serverError === 'auth')
|
||||
return displayError('access_denied');
|
||||
if(serverError === 'csrf')
|
||||
return displayError('invalid_request', 'Request verification failed.');
|
||||
if(serverError === 'authorise')
|
||||
return displayError('server_error', 'Server was unable to complete authorisation.');
|
||||
if(serverError === 'client')
|
||||
return displayError('invalid_request', 'There is no application associated with the specified Client ID.');
|
||||
if(serverError === 'csrf')
|
||||
return displayError('invalid_request', 'Request verification failed.');
|
||||
if(serverError === 'format')
|
||||
return displayError('invalid_request', 'Redirect URI specified is not registered with this application.');
|
||||
if(serverError === 'method')
|
||||
return displayError('invalid_request', 'Requested code challenge method is not supported.');
|
||||
if(serverError === 'length')
|
||||
return displayError('invalid_request', 'Code challenge length is not acceptable.');
|
||||
if(serverError === 'method')
|
||||
return displayError('invalid_request', 'Requested code challenge method is not supported.');
|
||||
if(serverError === 'resptype')
|
||||
return displayError('unsupported_response_type');
|
||||
if(serverError === 'required')
|
||||
return displayError('invalid_request', 'A registered redirect URI must be specified.');
|
||||
if(serverError === 'scope')
|
||||
return displayError('invalid_scope', detail === undefined ? undefined : `Requested scope "${detail.scope}" is ${detail.reason}.`);
|
||||
if(serverError === 'authorise')
|
||||
return displayError('server_error', 'Server was unable to complete authorisation.');
|
||||
|
||||
return displayError('invalid_request', `An unknown error occurred: ${serverError}.`);
|
||||
};
|
||||
|
@ -119,7 +121,9 @@ const MszOAuth2Authorise = async () => {
|
|||
state = qState;
|
||||
}
|
||||
|
||||
if(queryParams.get('response_type') !== 'code')
|
||||
const responseTypeArr = (queryParams.get('response_type') ?? '').split(' ').sort();
|
||||
const responseTypeStr = responseTypeArr.join(' ');
|
||||
if(!['code', 'code id_token'].includes(responseTypeStr))
|
||||
return displayError('unsupported_response_type');
|
||||
|
||||
let codeChallengeMethod = 'plain';
|
||||
|
@ -176,6 +180,7 @@ const MszOAuth2Authorise = async () => {
|
|||
client: queryParams.get('client_id'),
|
||||
cc: codeChallenge,
|
||||
ccm: codeChallengeMethod,
|
||||
rt: responseTypeStr,
|
||||
};
|
||||
if(redirectUriRaw !== undefined)
|
||||
params.redirect = redirectUriRaw;
|
||||
|
@ -191,6 +196,8 @@ const MszOAuth2Authorise = async () => {
|
|||
|
||||
const authoriseUri = new URL(body.redirect);
|
||||
authoriseUri.searchParams.set('code', body.code);
|
||||
if(body.id_token !== undefined)
|
||||
authoriseUri.searchParams.set('id_token', body.id_token);
|
||||
if(state !== undefined)
|
||||
authoriseUri.searchParams.set('state', state.toString());
|
||||
|
||||
|
|
|
@ -12,7 +12,8 @@
|
|||
"sentry/sdk": "~4.0",
|
||||
"nesbot/carbon": "~3.8",
|
||||
"vlucas/phpdotenv": "~5.6",
|
||||
"filp/whoops": "~2.17"
|
||||
"filp/whoops": "~2.17",
|
||||
"phpseclib/phpseclib": "~3.0"
|
||||
},
|
||||
"autoload": {
|
||||
"classmap": [
|
||||
|
|
360
composer.lock
generated
360
composer.lock
generated
|
@ -4,7 +4,7 @@
|
|||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||
"This file is @generated automatically"
|
||||
],
|
||||
"content-hash": "ba6f5f293f0f3e3c7ad9b5335aee2b36",
|
||||
"content-hash": "42e27bedc2ac1251eca61bf7df4f0342",
|
||||
"packages": [
|
||||
{
|
||||
"name": "carbonphp/carbon-doctrine-types",
|
||||
|
@ -955,16 +955,16 @@
|
|||
},
|
||||
{
|
||||
"name": "nesbot/carbon",
|
||||
"version": "3.8.4",
|
||||
"version": "3.8.6",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/CarbonPHP/carbon.git",
|
||||
"reference": "129700ed449b1f02d70272d2ac802357c8c30c58"
|
||||
"reference": "ff2f20cf83bd4d503720632ce8a426dc747bf7fd"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/CarbonPHP/carbon/zipball/129700ed449b1f02d70272d2ac802357c8c30c58",
|
||||
"reference": "129700ed449b1f02d70272d2ac802357c8c30c58",
|
||||
"url": "https://api.github.com/repos/CarbonPHP/carbon/zipball/ff2f20cf83bd4d503720632ce8a426dc747bf7fd",
|
||||
"reference": "ff2f20cf83bd4d503720632ce8a426dc747bf7fd",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
|
@ -1040,8 +1040,8 @@
|
|||
],
|
||||
"support": {
|
||||
"docs": "https://carbon.nesbot.com/docs",
|
||||
"issues": "https://github.com/briannesbitt/Carbon/issues",
|
||||
"source": "https://github.com/briannesbitt/Carbon"
|
||||
"issues": "https://github.com/CarbonPHP/carbon/issues",
|
||||
"source": "https://github.com/CarbonPHP/carbon"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
|
@ -1057,7 +1057,124 @@
|
|||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2024-12-27T09:25:35+00:00"
|
||||
"time": "2025-02-20T17:33:38+00:00"
|
||||
},
|
||||
{
|
||||
"name": "paragonie/constant_time_encoding",
|
||||
"version": "v3.0.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/paragonie/constant_time_encoding.git",
|
||||
"reference": "df1e7fde177501eee2037dd159cf04f5f301a512"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/paragonie/constant_time_encoding/zipball/df1e7fde177501eee2037dd159cf04f5f301a512",
|
||||
"reference": "df1e7fde177501eee2037dd159cf04f5f301a512",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": "^8"
|
||||
},
|
||||
"require-dev": {
|
||||
"phpunit/phpunit": "^9",
|
||||
"vimeo/psalm": "^4|^5"
|
||||
},
|
||||
"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": "2024-05-08T12:36:18+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": "phpoption/phpoption",
|
||||
|
@ -1134,6 +1251,116 @@
|
|||
],
|
||||
"time": "2024-07-20T21:41:07+00:00"
|
||||
},
|
||||
{
|
||||
"name": "phpseclib/phpseclib",
|
||||
"version": "3.0.43",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/phpseclib/phpseclib.git",
|
||||
"reference": "709ec107af3cb2f385b9617be72af8cf62441d02"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/phpseclib/phpseclib/zipball/709ec107af3cb2f385b9617be72af8cf62441d02",
|
||||
"reference": "709ec107af3cb2f385b9617be72af8cf62441d02",
|
||||
"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.43"
|
||||
},
|
||||
"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": "2024-12-14T21:12:59+00:00"
|
||||
},
|
||||
{
|
||||
"name": "psr/clock",
|
||||
"version": "1.0.0",
|
||||
|
@ -2562,82 +2789,6 @@
|
|||
],
|
||||
"time": "2024-09-09T11:45:10+00:00"
|
||||
},
|
||||
{
|
||||
"name": "symfony/polyfill-php81",
|
||||
"version": "v1.31.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/symfony/polyfill-php81.git",
|
||||
"reference": "4a4cfc2d253c21a5ad0e53071df248ed48c6ce5c"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/symfony/polyfill-php81/zipball/4a4cfc2d253c21a5ad0e53071df248ed48c6ce5c",
|
||||
"reference": "4a4cfc2d253c21a5ad0e53071df248ed48c6ce5c",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": ">=7.2"
|
||||
},
|
||||
"type": "library",
|
||||
"extra": {
|
||||
"thanks": {
|
||||
"url": "https://github.com/symfony/polyfill",
|
||||
"name": "symfony/polyfill"
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
"files": [
|
||||
"bootstrap.php"
|
||||
],
|
||||
"psr-4": {
|
||||
"Symfony\\Polyfill\\Php81\\": ""
|
||||
},
|
||||
"classmap": [
|
||||
"Resources/stubs"
|
||||
]
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Nicolas Grekas",
|
||||
"email": "p@tchwork.com"
|
||||
},
|
||||
{
|
||||
"name": "Symfony Community",
|
||||
"homepage": "https://symfony.com/contributors"
|
||||
}
|
||||
],
|
||||
"description": "Symfony polyfill backporting some PHP 8.1+ features to lower PHP versions",
|
||||
"homepage": "https://symfony.com",
|
||||
"keywords": [
|
||||
"compatibility",
|
||||
"polyfill",
|
||||
"portable",
|
||||
"shim"
|
||||
],
|
||||
"support": {
|
||||
"source": "https://github.com/symfony/polyfill-php81/tree/v1.31.0"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://symfony.com/sponsor",
|
||||
"type": "custom"
|
||||
},
|
||||
{
|
||||
"url": "https://github.com/fabpot",
|
||||
"type": "github"
|
||||
},
|
||||
{
|
||||
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2024-09-09T11:45:10+00:00"
|
||||
},
|
||||
{
|
||||
"name": "symfony/polyfill-php83",
|
||||
"version": "v1.31.0",
|
||||
|
@ -2972,20 +3123,20 @@
|
|||
},
|
||||
{
|
||||
"name": "twig/html-extra",
|
||||
"version": "v3.19.0",
|
||||
"version": "v3.20.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/twigphp/html-extra.git",
|
||||
"reference": "c63b28e192c1b7c15bb60f81d2e48b140846239a"
|
||||
"reference": "f7d54d4de1b64182af745cfb66777f699b599734"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/twigphp/html-extra/zipball/c63b28e192c1b7c15bb60f81d2e48b140846239a",
|
||||
"reference": "c63b28e192c1b7c15bb60f81d2e48b140846239a",
|
||||
"url": "https://api.github.com/repos/twigphp/html-extra/zipball/f7d54d4de1b64182af745cfb66777f699b599734",
|
||||
"reference": "f7d54d4de1b64182af745cfb66777f699b599734",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": ">=8.0.2",
|
||||
"php": ">=8.1.0",
|
||||
"symfony/deprecation-contracts": "^2.5|^3",
|
||||
"symfony/mime": "^5.4|^6.4|^7.0",
|
||||
"twig/twig": "^3.13|^4.0"
|
||||
|
@ -3024,7 +3175,7 @@
|
|||
"twig"
|
||||
],
|
||||
"support": {
|
||||
"source": "https://github.com/twigphp/html-extra/tree/v3.19.0"
|
||||
"source": "https://github.com/twigphp/html-extra/tree/v3.20.0"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
|
@ -3036,28 +3187,27 @@
|
|||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2024-12-29T10:29:59+00:00"
|
||||
"time": "2025-01-31T20:45:36+00:00"
|
||||
},
|
||||
{
|
||||
"name": "twig/twig",
|
||||
"version": "v3.19.0",
|
||||
"version": "v3.20.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/twigphp/Twig.git",
|
||||
"reference": "d4f8c2b86374f08efc859323dbcd95c590f7124e"
|
||||
"reference": "3468920399451a384bef53cf7996965f7cd40183"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/twigphp/Twig/zipball/d4f8c2b86374f08efc859323dbcd95c590f7124e",
|
||||
"reference": "d4f8c2b86374f08efc859323dbcd95c590f7124e",
|
||||
"url": "https://api.github.com/repos/twigphp/Twig/zipball/3468920399451a384bef53cf7996965f7cd40183",
|
||||
"reference": "3468920399451a384bef53cf7996965f7cd40183",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": ">=8.0.2",
|
||||
"php": ">=8.1.0",
|
||||
"symfony/deprecation-contracts": "^2.5|^3",
|
||||
"symfony/polyfill-ctype": "^1.8",
|
||||
"symfony/polyfill-mbstring": "^1.3",
|
||||
"symfony/polyfill-php81": "^1.29"
|
||||
"symfony/polyfill-mbstring": "^1.3"
|
||||
},
|
||||
"require-dev": {
|
||||
"phpstan/phpstan": "^2.0",
|
||||
|
@ -3104,7 +3254,7 @@
|
|||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/twigphp/Twig/issues",
|
||||
"source": "https://github.com/twigphp/Twig/tree/v3.19.0"
|
||||
"source": "https://github.com/twigphp/Twig/tree/v3.20.0"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
|
@ -3116,7 +3266,7 @@
|
|||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2025-01-29T07:06:14+00:00"
|
||||
"time": "2025-02-13T08:34:43+00:00"
|
||||
},
|
||||
{
|
||||
"name": "vlucas/phpdotenv",
|
||||
|
@ -3206,16 +3356,16 @@
|
|||
"packages-dev": [
|
||||
{
|
||||
"name": "phpstan/phpstan",
|
||||
"version": "2.1.3",
|
||||
"version": "2.1.6",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/phpstan/phpstan.git",
|
||||
"reference": "64ae44e48214f3deebdaeebf2694297a10a2bea9"
|
||||
"reference": "6eaec7c6c9e90dcfe46ad1e1ffa5171e2dab641c"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/phpstan/phpstan/zipball/64ae44e48214f3deebdaeebf2694297a10a2bea9",
|
||||
"reference": "64ae44e48214f3deebdaeebf2694297a10a2bea9",
|
||||
"url": "https://api.github.com/repos/phpstan/phpstan/zipball/6eaec7c6c9e90dcfe46ad1e1ffa5171e2dab641c",
|
||||
"reference": "6eaec7c6c9e90dcfe46ad1e1ffa5171e2dab641c",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
|
@ -3260,7 +3410,7 @@
|
|||
"type": "github"
|
||||
}
|
||||
],
|
||||
"time": "2025-02-07T15:05:24+00:00"
|
||||
"time": "2025-02-19T15:46:42+00:00"
|
||||
}
|
||||
],
|
||||
"aliases": [],
|
||||
|
|
1
public/images/flashii-colour.svg
Normal file
1
public/images/flashii-colour.svg
Normal file
|
@ -0,0 +1 @@
|
|||
<svg width="16" height="16" viewBox="0 0 16 16" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule: evenodd; clip-rule: evenodd; stroke-linejoin: round; stroke-miterlimit: 2; cursor: default;"><g id="g285"><path id="rect18" d="M13.564,9.491l-1.16,4.327l-0.778,-0.208l1.16,-4.328l0.778,0.209Z" style="fill:#846d77;"/><path id="rect16" d="M12.776,7.254l-1.159,4.328l-0.778,-0.209l1.159,-4.327l0.778,0.208Z" style="fill:#826879;"/><path id="rect14" d="M11.679,6.174l-1.159,4.327l-0.778,-0.208l1.159,-4.327l0.778,0.208Z" style="fill:#7e647a;"/><path id="rect12" d="M10.376,5.865l-1.16,4.327l-0.778,-0.209l1.16,-4.327l0.778,0.209Z" style="fill:#795d7e;"/><path id="rect10" d="M8.969,5.941l-1.16,4.327l-0.778,-0.209l1.16,-4.327l0.778,0.209Z" style="fill:#75567f;"/><path id="rect8" d="M7.562,6.017l-1.16,4.327l-0.778,-0.209l1.16,-4.327l0.778,0.209Z" style="fill:#704f81;"/><path id="rect6" d="M6.258,5.707l-1.159,4.327l-0.778,-0.208l1.159,-4.327l0.778,0.208Z" style="fill:#6b4882;"/><path id="rect4" d="M5.161,4.627l-1.159,4.327l-0.778,-0.208l1.159,-4.328l0.778,0.209Z" style="fill:#674183;"/><path id="rect2" d="M4.374,2.39l-1.16,4.328l-0.778,-0.209l1.16,-4.327l0.778,0.208Z" style="fill:#643b84;"/></g></svg>
|
After Width: | Height: | Size: 1.3 KiB |
|
@ -35,6 +35,7 @@ class MisuzuContext {
|
|||
public private(set) Forum\ForumContext $forumCtx;
|
||||
public private(set) Messages\MessagesContext $messagesCtx;
|
||||
public private(set) OAuth2\OAuth2Context $oauth2Ctx;
|
||||
public private(set) OpenID\OpenIDContext $openIdCtx;
|
||||
public private(set) Profile\ProfileContext $profileCtx;
|
||||
public private(set) Users\UsersContext $usersCtx;
|
||||
public private(set) Redirects\RedirectsContext $redirectsCtx;
|
||||
|
@ -72,6 +73,7 @@ class MisuzuContext {
|
|||
$this->deps->register($this->forumCtx = $this->deps->constructLazy(Forum\ForumContext::class));
|
||||
$this->deps->register($this->messagesCtx = $this->deps->constructLazy(Messages\MessagesContext::class));
|
||||
$this->deps->register($this->oauth2Ctx = $this->deps->constructLazy(OAuth2\OAuth2Context::class, config: $this->config->scopeTo('oauth2')));
|
||||
$this->deps->register($this->openIdCtx = $this->deps->constructLazy(OpenID\OpenIDContext::class, config: $this->config->scopeTo('openid')));
|
||||
$this->deps->register($this->profileCtx = $this->deps->constructLazy(Profile\ProfileContext::class));
|
||||
$this->deps->register($this->usersCtx = $this->deps->constructLazy(Users\UsersContext::class));
|
||||
$this->deps->register($this->redirectsCtx = $this->deps->constructLazy(Redirects\RedirectsContext::class, config: $this->config->scopeTo('redirects')));
|
||||
|
@ -169,6 +171,13 @@ class MisuzuContext {
|
|||
RoutingContext $routingCtx,
|
||||
RpcServer $rpcServer
|
||||
): void {
|
||||
$this->deps->register($wf = new WebFinger\WebFingerRegistry);
|
||||
$wf->register($this->deps->constructLazy(
|
||||
Users\UsersWebFingerResolver::class,
|
||||
config: $this->config->scopeTo('users')
|
||||
));
|
||||
$routingCtx->register($this->deps->constructLazy(WebFinger\WebFingerRoutes::class));
|
||||
|
||||
$routingCtx->register($this->deps->constructLazy(Home\HomeRoutes::class));
|
||||
$routingCtx->register($this->deps->constructLazy(Users\Assets\AssetsRoutes::class));
|
||||
$routingCtx->register($this->deps->constructLazy(Info\InfoRoutes::class));
|
||||
|
@ -183,6 +192,8 @@ class MisuzuContext {
|
|||
$routingCtx->register($this->deps->constructLazy(OAuth2\OAuth2ApiRoutes::class));
|
||||
$routingCtx->register($this->deps->constructLazy(OAuth2\OAuth2WebRoutes::class));
|
||||
$rpcServer->register($this->deps->constructLazy(OAuth2\OAuth2RpcHandler::class));
|
||||
$routingCtx->register($this->deps->constructLazy(OpenID\OpenIDRoutes::class));
|
||||
$routingCtx->register($this->deps->constructLazy(WebFinger\WebFingerRoutes::class));
|
||||
|
||||
$routingCtx->register($this->deps->constructLazy(Forum\ForumCategoriesRoutes::class));
|
||||
$routingCtx->register($this->deps->constructLazy(Forum\ForumTopicsRoutes::class));
|
||||
|
|
|
@ -4,12 +4,13 @@ namespace Misuzu\OAuth2;
|
|||
use RuntimeException;
|
||||
use Index\Http\{FormHttpContent,HttpResponseBuilder,HttpRequest};
|
||||
use Index\Http\Routing\{HttpGet,HttpOptions,HttpPost,RouteHandler,RouteHandlerCommon};
|
||||
use Index\Urls\{UrlFormat,UrlSource,UrlSourceCommon};
|
||||
|
||||
final class OAuth2ApiRoutes implements RouteHandler {
|
||||
use RouteHandlerCommon;
|
||||
final class OAuth2ApiRoutes implements RouteHandler, UrlSource {
|
||||
use RouteHandlerCommon, UrlSourceCommon;
|
||||
|
||||
public function __construct(
|
||||
private OAuth2Context $oauth2Ctx
|
||||
private OAuth2Context $oauth2Ctx,
|
||||
) {}
|
||||
|
||||
/**
|
||||
|
@ -49,6 +50,7 @@ final class OAuth2ApiRoutes implements RouteHandler {
|
|||
*/
|
||||
#[HttpPost('/oauth2/request-authorise')]
|
||||
#[HttpPost('/oauth2/request-authorize')]
|
||||
#[UrlFormat('oauth2-request-authorise', '/oauth2/request-authorize')]
|
||||
public function postRequestAuthorise(HttpResponseBuilder $response, HttpRequest $request): array {
|
||||
$response->setHeader('Cache-Control', 'no-store');
|
||||
|
||||
|
@ -108,6 +110,7 @@ final class OAuth2ApiRoutes implements RouteHandler {
|
|||
*/
|
||||
#[HttpOptions('/oauth2/token')]
|
||||
#[HttpPost('/oauth2/token')]
|
||||
#[UrlFormat('oauth2-token', '/oauth2/token')]
|
||||
public function postToken(HttpResponseBuilder $response, HttpRequest $request): array|int {
|
||||
$response->setHeader('Cache-Control', 'no-store');
|
||||
|
||||
|
|
|
@ -5,6 +5,7 @@ use RuntimeException;
|
|||
use Index\Config\Config;
|
||||
use Index\Db\DbConnection;
|
||||
use Misuzu\Apps\{AppsContext,AppInfo};
|
||||
use Misuzu\OpenID\OpenIDContext;
|
||||
use Misuzu\Users\UserInfo;
|
||||
|
||||
class OAuth2Context {
|
||||
|
@ -15,7 +16,8 @@ class OAuth2Context {
|
|||
public function __construct(
|
||||
private Config $config,
|
||||
DbConnection $dbConn,
|
||||
public private(set) AppsContext $appsCtx
|
||||
public private(set) AppsContext $appsCtx,
|
||||
private OpenIDContext $openIdCtx,
|
||||
) {
|
||||
$this->authorisations = new OAuth2AuthorisationData($dbConn);
|
||||
$this->tokens = new OAuth2TokensData($dbConn);
|
||||
|
@ -146,6 +148,7 @@ class OAuth2Context {
|
|||
* }
|
||||
*/
|
||||
public function packBearerTokenResult(
|
||||
AppInfo $appInfo,
|
||||
OAuth2AccessInfo $accessInfo,
|
||||
?OAuth2RefreshInfo $refreshInfo = null,
|
||||
?string $scope = null
|
||||
|
@ -165,6 +168,9 @@ class OAuth2Context {
|
|||
if($refreshInfo !== null)
|
||||
$result['refresh_token'] = $refreshInfo->token;
|
||||
|
||||
if(in_array('openid', $accessInfo->scopes))
|
||||
$result['id_token'] = $this->openIdCtx->createIdToken($appInfo, $accessInfo);
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
|
@ -218,7 +224,7 @@ class OAuth2Context {
|
|||
? $this->createRefresh($appInfo, $accessInfo)
|
||||
: null;
|
||||
|
||||
return $this->packBearerTokenResult($accessInfo, $refreshInfo, $scope);
|
||||
return $this->packBearerTokenResult($appInfo, $accessInfo, $refreshInfo, $scope);
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -279,7 +285,7 @@ class OAuth2Context {
|
|||
if($appInfo->issueRefreshToken)
|
||||
$refreshInfo = $this->createRefresh($appInfo, $accessInfo);
|
||||
|
||||
return $this->packBearerTokenResult($accessInfo, $refreshInfo, $scope);
|
||||
return $this->packBearerTokenResult($appInfo, $accessInfo, $refreshInfo, $scope);
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -316,7 +322,7 @@ class OAuth2Context {
|
|||
if($requestedScope === null || $scope === $requestedScope)
|
||||
$scope = null;
|
||||
|
||||
return $this->packBearerTokenResult($accessInfo, scope: $scope);
|
||||
return $this->packBearerTokenResult($appInfo, $accessInfo, scope: $scope);
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -393,6 +399,6 @@ class OAuth2Context {
|
|||
? $this->createRefresh($appInfo, $accessInfo)
|
||||
: null;
|
||||
|
||||
return $this->packBearerTokenResult($accessInfo, $refreshInfo, $scope);
|
||||
return $this->packBearerTokenResult($appInfo, $accessInfo, $refreshInfo, $scope);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -3,26 +3,32 @@ namespace Misuzu\OAuth2;
|
|||
|
||||
use InvalidArgumentException;
|
||||
use RuntimeException;
|
||||
use Index\XArray;
|
||||
use Index\Http\{FormHttpContent,HttpResponseBuilder,HttpRequest};
|
||||
use Index\Http\Routing\{HttpGet,HttpPost,RouteHandler,RouteHandlerCommon};
|
||||
use Index\Urls\UrlRegistry;
|
||||
use Misuzu\{CSRF,SiteInfo,Template};
|
||||
use Index\Urls\{UrlFormat,UrlRegistry,UrlSource,UrlSourceCommon};
|
||||
use Misuzu\{CSRF,Template};
|
||||
use Misuzu\Auth\AuthInfo;
|
||||
use Misuzu\OpenID\OpenIDContext;
|
||||
use Misuzu\Users\UsersContext;
|
||||
|
||||
final class OAuth2WebRoutes implements RouteHandler {
|
||||
use RouteHandlerCommon;
|
||||
final class OAuth2WebRoutes implements RouteHandler, UrlSource {
|
||||
use RouteHandlerCommon, UrlSourceCommon;
|
||||
|
||||
/** @var string[] */
|
||||
private const array RESPONSE_TYPES = ['code', 'code id_token'];
|
||||
|
||||
public function __construct(
|
||||
private OAuth2Context $oauth2Ctx,
|
||||
private OpenIDContext $openIdCtx,
|
||||
private UsersContext $usersCtx,
|
||||
private UrlRegistry $urls,
|
||||
private AuthInfo $authInfo
|
||||
) {
|
||||
}
|
||||
private AuthInfo $authInfo,
|
||||
) {}
|
||||
|
||||
#[HttpGet('/oauth2/authorise')]
|
||||
#[HttpGet('/oauth2/authorize')]
|
||||
#[UrlFormat('oauth2-authorise', '/oauth2/authorize')]
|
||||
public function getAuthorise(HttpResponseBuilder $response, HttpRequest $request): string {
|
||||
return Template::renderRaw('oauth2.authorise');
|
||||
}
|
||||
|
@ -52,6 +58,12 @@ final class OAuth2WebRoutes implements RouteHandler {
|
|||
if(!$this->authInfo->loggedIn)
|
||||
return ['error' => 'auth'];
|
||||
|
||||
$responseTypes = explode(' ', (string)$request->content->getParam('rt'), 2);
|
||||
sort($responseTypes);
|
||||
$responseType = implode(' ', $responseTypes);
|
||||
if(!in_array($responseType, self::RESPONSE_TYPES))
|
||||
return ['error' => 'resptype'];
|
||||
|
||||
$codeChallengeMethod = 'plain';
|
||||
if($request->content->hasParam('ccm')) {
|
||||
$codeChallengeMethod = $request->content->getParam('ccm');
|
||||
|
@ -120,10 +132,15 @@ final class OAuth2WebRoutes implements RouteHandler {
|
|||
return ['error' => 'authorise', 'detail' => $ex->getMessage()];
|
||||
}
|
||||
|
||||
return [
|
||||
'code' => $authsInfo->code,
|
||||
$result = [
|
||||
'redirect' => $redirectUri,
|
||||
'code' => $authsInfo->code,
|
||||
];
|
||||
|
||||
if(in_array('id_token', $responseTypes))
|
||||
$result['id_token'] = $this->openIdCtx->createIdToken($appInfo, $authsInfo);
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -155,6 +172,7 @@ final class OAuth2WebRoutes implements RouteHandler {
|
|||
* }
|
||||
*/
|
||||
#[HttpGet('/oauth2/resolve-authorise-app')]
|
||||
#[UrlFormat('oauth2-resolve-authorise-app', '/oauth2/resolve-authorise-app')]
|
||||
public function getResolveAuthorise(HttpResponseBuilder $response, HttpRequest $request): array {
|
||||
// TODO: RATE LIMITING
|
||||
|
||||
|
@ -228,6 +246,7 @@ final class OAuth2WebRoutes implements RouteHandler {
|
|||
}
|
||||
|
||||
#[HttpGet('/oauth2/verify')]
|
||||
#[UrlFormat('oauth2-verify', '/oauth2/verify')]
|
||||
public function getVerify(HttpResponseBuilder $response, HttpRequest $request): string {
|
||||
return Template::renderRaw('oauth2.verify');
|
||||
}
|
||||
|
@ -341,6 +360,7 @@ final class OAuth2WebRoutes implements RouteHandler {
|
|||
* }
|
||||
*/
|
||||
#[HttpGet('/oauth2/resolve-verify')]
|
||||
#[UrlFormat('oauth2-resolve-verify', '/oauth2/resolve-verify')]
|
||||
public function getResolveVerify(HttpResponseBuilder $response, HttpRequest $request) {
|
||||
// TODO: RATE LIMITING
|
||||
|
||||
|
|
71
src/OpenID/ArrayJWTKeySet.php
Normal file
71
src/OpenID/ArrayJWTKeySet.php
Normal file
|
@ -0,0 +1,71 @@
|
|||
<?php
|
||||
namespace Misuzu\OpenID;
|
||||
|
||||
use RuntimeException;
|
||||
use Index\UriBase64;
|
||||
use phpseclib3\Crypt\PublicKeyLoader;
|
||||
use phpseclib3\Crypt\Common\{AsymmetricKey,PrivateKey,PublicKey};
|
||||
use phpseclib3\Math\BigInteger;
|
||||
|
||||
class ArrayJWTKeySet implements JWTKeySet {
|
||||
/** @param array<string, JWTKey> $keys */
|
||||
public function __construct(public private(set) array $keys) {}
|
||||
|
||||
public function getKey(?string $keyId = null, ?string $alg = null): JWTKey {
|
||||
if($keyId === null) {
|
||||
if($alg === null)
|
||||
return $this->keys[array_key_first($this->keys)];
|
||||
|
||||
foreach($this->keys as $key)
|
||||
if(hash_equals($key->algo, $alg))
|
||||
return $key;
|
||||
|
||||
throw new RuntimeException('could not find a key that matched the requested algorithm');
|
||||
}
|
||||
|
||||
if(!array_key_exists($keyId, $this->keys))
|
||||
throw new RuntimeException('could not find a key with that id');
|
||||
|
||||
$key = $this->keys[$keyId];
|
||||
if($alg !== null && !hash_equals($key->algo, $alg))
|
||||
throw new RuntimeException('requested algorithm does not match');
|
||||
|
||||
return $key;
|
||||
}
|
||||
|
||||
public function count(): int {
|
||||
return count($this->keys);
|
||||
}
|
||||
|
||||
public static function decodeJson(string|array|object $json): ArrayJWTKeySet {
|
||||
if(is_object($json))
|
||||
$json = (array)$json;
|
||||
elseif(is_string($json))
|
||||
$json = json_decode($json);
|
||||
|
||||
if(is_array($json))
|
||||
$json = (object)$json;
|
||||
|
||||
if(!property_exists($json, 'keys') || !is_array($json->keys) || !array_is_list($json->keys))
|
||||
return new ArrayJWTKeySet([]);
|
||||
|
||||
$keys = [];
|
||||
|
||||
foreach($json->keys as $keyInfo) {
|
||||
$key = PublicKeyLoader::load(json_encode($keyInfo));
|
||||
|
||||
if($key instanceof AsymmetricKey && ($key instanceof PrivateKey || $key instanceof PublicKey))
|
||||
$key = new SecLibJWTKey(
|
||||
property_exists($keyInfo, 'alg') && is_string($keyInfo->alg) ? $keyInfo->alg : SetLibJWTKey::inferAlgorithm($key),
|
||||
$key
|
||||
);
|
||||
|
||||
if(property_exists($keyInfo, 'kid') && is_string($keyInfo->kid))
|
||||
$keys[$keyInfo->kid] = $key;
|
||||
else
|
||||
$keys[] = $key;
|
||||
}
|
||||
|
||||
return new ArrayJWTKeySet($keys);
|
||||
}
|
||||
}
|
33
src/OpenID/HMACJWTKey.php
Normal file
33
src/OpenID/HMACJWTKey.php
Normal file
|
@ -0,0 +1,33 @@
|
|||
<?php
|
||||
namespace Misuzu\OpenID;
|
||||
|
||||
use InvalidArgumentException;
|
||||
|
||||
class HMACJWTKey implements JWTKey {
|
||||
public const string DEFAULT_ALGO = 'HS256';
|
||||
public const array ALGOS = [
|
||||
'HS256' => 'sha256',
|
||||
'HS384' => 'sha384',
|
||||
'HS512' => 'sha512',
|
||||
];
|
||||
|
||||
private string $realAlgo;
|
||||
|
||||
public function __construct(
|
||||
public private(set) string $algo,
|
||||
#[\SensitiveParameter] private string $key
|
||||
) {
|
||||
if(!array_key_exists($algo, self::ALGOS))
|
||||
throw new InvalidArgumentException('unsupported algorithm');
|
||||
|
||||
$this->realAlgo = self::ALGOS[$algo];
|
||||
}
|
||||
|
||||
public function sign(string $data): string {
|
||||
return hash_hmac($this->realAlgo, $data, $this->key, true);
|
||||
}
|
||||
|
||||
public function verify(string $data, string $signature): bool {
|
||||
return hash_equals($this->sign($data), $signature);
|
||||
}
|
||||
}
|
103
src/OpenID/JWT.php
Normal file
103
src/OpenID/JWT.php
Normal file
|
@ -0,0 +1,103 @@
|
|||
<?php
|
||||
namespace Misuzu\OpenID;
|
||||
|
||||
use InvalidArgumentException;
|
||||
use RuntimeException;
|
||||
use UnexpectedValueException;
|
||||
use Index\{UriBase64,XDateTime};
|
||||
use phpseclib3\Crypt\Common\{AsymmetricKey,PrivateKey,PublicKey};
|
||||
|
||||
// based on https://github.com/firebase/php-jwt/blob/8f718f4dfc9c5d5f0c994cdfd103921b43592712/src/JWT.php
|
||||
class JWT {
|
||||
public static function encode(
|
||||
array|object $payload,
|
||||
JWTKey|(AsymmetricKey&PrivateKey)|string $key,
|
||||
?string $keyId = null,
|
||||
?string $alg = null,
|
||||
array|object|null $header = null
|
||||
): string {
|
||||
if(is_string($key))
|
||||
$key = new HMACJWTKey($alg ?? HMACJWTKey::DEFAULT_ALGO, $key);
|
||||
elseif($key instanceof AsymmetricKey)
|
||||
$key = new SecLibJWTKey($alg ?? SecLibJWTKey::inferAlgorithm($key), $key);
|
||||
|
||||
$head = ['typ' => 'JWT'];
|
||||
if($header !== null)
|
||||
$head = array_merge($head, (array)$header);
|
||||
$head['alg'] = $key->algo;
|
||||
if($keyId !== null)
|
||||
$head['kid'] = $keyId;
|
||||
|
||||
$encoded = sprintf('%s.%s', UriBase64::encode(json_encode($head)), UriBase64::encode(json_encode($payload)));
|
||||
$encoded = sprintf('%s.%s', $encoded, UriBase64::encode($key->sign($encoded)));
|
||||
|
||||
return $encoded;
|
||||
}
|
||||
|
||||
public static function decode(
|
||||
string $token,
|
||||
array|JWTKeySet|JWTKey|(AsymmetricKey&PrivateKey)|(AsymmetricKey&PublicKey)|string $keys,
|
||||
array|object|null $headers = null,
|
||||
?int $timestamp = null,
|
||||
int $leeway = 0,
|
||||
bool $asObject = true
|
||||
): array|object {
|
||||
$timestamp ??= time();
|
||||
|
||||
if(is_array($keys))
|
||||
$keys = new ArrayJWTKeySet($keys);
|
||||
elseif($keys instanceof JWTKey || $keys instanceof AsymmetricKey || is_string($keys))
|
||||
$keys = new SingleJWTKeySet($keys);
|
||||
|
||||
if(count($keys) < 1)
|
||||
throw new InvalidArgumentException('$keys may not be empty');
|
||||
|
||||
$parts = explode('.', $token, 4);
|
||||
if(count($parts) !== 3)
|
||||
throw new InvalidArgumentException('wrong amount of segments');
|
||||
|
||||
[$headerEnc, $payloadEnc, $sigEnc] = $parts;
|
||||
$headerRaw = UriBase64::decode($headerEnc);
|
||||
$header = json_decode($headerRaw, true);
|
||||
if(empty($header))
|
||||
throw new UnexpectedValueException('unexpected header encoding');
|
||||
if($headers !== null)
|
||||
$header = (array)$headers;
|
||||
|
||||
$payloadRaw = UriBase64::decode($payloadEnc);
|
||||
$payload = json_decode($payloadRaw, true);
|
||||
if(empty($payload))
|
||||
throw new UnexpectedValueException('unexpected payload encoding');
|
||||
if(!is_array($payload) || array_is_list($payload))
|
||||
throw new UnexpectedValueException('payload must be a JSON object');
|
||||
|
||||
$sig = UriBase64::decode($sigEnc);
|
||||
if(empty($header['alg']) || !is_string($header['alg']))
|
||||
throw new UnexpectedValueException('algorithm is not set');
|
||||
|
||||
$key = $keys->getKey(
|
||||
array_key_exists('kid', $header) && is_string($header['kid']) ? $header['kid'] : null,
|
||||
$header['alg']
|
||||
);
|
||||
|
||||
if(!$key->verify(sprintf('%s.%s', $headerEnc, $payloadEnc), $sig))
|
||||
throw new RuntimeException('signature verification failed');
|
||||
|
||||
if(array_key_exists('nbf', $payload) && (is_int($payload['nbf']) || is_float($payload['nbf']))) {
|
||||
if(floor($payload['nbf']) > ($timestamp + $leeway))
|
||||
throw new RuntimeException(sprintf('token is not valid before %s', XDateTime::toIso8601String((int)floor($payload['nbf']))));
|
||||
} elseif(array_key_exists('iat', $payload) && (is_int($payload['iat']) || is_float($payload['iat']))) {
|
||||
if(floor($payload['iat']) > ($timestamp + $leeway))
|
||||
throw new RuntimeException(sprintf('token is not valid before %s', XDateTime::toIso8601String((int)floor($payload['iat']))));
|
||||
}
|
||||
|
||||
if(array_key_exists('exp', $payload) && (is_int($payload['exp']) || is_float($payload['exp']))
|
||||
&& $payload['exp'] < ($timestamp + $leeway))
|
||||
throw new RuntimeException('expired token');
|
||||
|
||||
if($asObject)
|
||||
$payload = (object)$payload;
|
||||
|
||||
return $payload;
|
||||
}
|
||||
}
|
12
src/OpenID/JWTKey.php
Normal file
12
src/OpenID/JWTKey.php
Normal file
|
@ -0,0 +1,12 @@
|
|||
<?php
|
||||
namespace Misuzu\OpenID;
|
||||
|
||||
use RuntimeException;
|
||||
|
||||
interface JWTKey {
|
||||
public string $algo { get; }
|
||||
|
||||
/** @throws RuntimeException if this instance can only be used for verification */
|
||||
public function sign(string $data): string;
|
||||
public function verify(string $data, string $signature): bool;
|
||||
}
|
13
src/OpenID/JWTKeySet.php
Normal file
13
src/OpenID/JWTKeySet.php
Normal file
|
@ -0,0 +1,13 @@
|
|||
<?php
|
||||
namespace Misuzu\OpenID;
|
||||
|
||||
use Countable;
|
||||
use RuntimeException;
|
||||
|
||||
interface JWTKeySet extends Countable {
|
||||
/** @var array<string, JWTKey> */
|
||||
public array $keys { get; }
|
||||
|
||||
/** @throws RuntimeException if no match could be found */
|
||||
public function getKey(?string $keyId = null, ?string $alg = null): JWTKey;
|
||||
}
|
178
src/OpenID/OpenIDContext.php
Normal file
178
src/OpenID/OpenIDContext.php
Normal file
|
@ -0,0 +1,178 @@
|
|||
<?php
|
||||
namespace Misuzu\OpenID;
|
||||
|
||||
use RuntimeException;
|
||||
use Index\Config\Config;
|
||||
use Misuzu\{Misuzu,SiteInfo};
|
||||
use Misuzu\Apps\AppInfo;
|
||||
use Misuzu\OAuth2\{OAuth2AccessInfo,OAuth2AuthorisationInfo};
|
||||
use Misuzu\Users\UserInfo;
|
||||
use phpseclib3\Crypt\PublicKeyLoader;
|
||||
use phpseclib3\Crypt\Common\{AsymmetricKey,PrivateKey,PublicKey};
|
||||
|
||||
class OpenIDContext {
|
||||
private const string KEY_FORMAT = '%s/keys/%s-%s.pem';
|
||||
|
||||
public function __construct(
|
||||
private SiteInfo $siteInfo,
|
||||
private Config $config,
|
||||
) {}
|
||||
|
||||
/** @var string[] */
|
||||
public array $keyIds {
|
||||
get => $this->config->getArray('keys');
|
||||
}
|
||||
|
||||
public string $websiteProfileField {
|
||||
get => $this->config->getString('website_profile_field');
|
||||
}
|
||||
|
||||
public static function getDataForIdToken(
|
||||
SiteInfo $siteInfo,
|
||||
AppInfo $appInfo,
|
||||
OAuth2AccessInfo|OAuth2AuthorisationInfo $accessOrAuthzInfo,
|
||||
?string $nonce = null,
|
||||
?int $issuedAt = null
|
||||
): array {
|
||||
$token = [
|
||||
'iss' => $siteInfo->url,
|
||||
'sub' => $accessOrAuthzInfo->userId,
|
||||
'aud' => $appInfo->clientId,
|
||||
'exp' => $accessOrAuthzInfo->expiresTime,
|
||||
'iat' => $issuedAt ?? time(),
|
||||
'auth_time' => $accessOrAuthzInfo->createdTime,
|
||||
];
|
||||
if($nonce !== null) // keep track of this somehow, must be taken from /oauth2/authorize args
|
||||
$token['nonce'] = $nonce;
|
||||
|
||||
return $token;
|
||||
}
|
||||
|
||||
public function createIdToken(
|
||||
AppInfo $appInfo,
|
||||
OAuth2AccessInfo|OAuth2AuthorisationInfo $accessOrAuthzInfo,
|
||||
?string $nonce = null,
|
||||
?int $issuedAt = null,
|
||||
?string $keyId = null
|
||||
): string {
|
||||
return JWT::encode(
|
||||
self::getDataForIdToken(
|
||||
$this->siteInfo,
|
||||
$appInfo,
|
||||
$accessOrAuthzInfo,
|
||||
$nonce,
|
||||
$issuedAt
|
||||
),
|
||||
$this->getPrivateJWTKeySet()->getKey($keyId),
|
||||
$keyId
|
||||
);
|
||||
}
|
||||
|
||||
public function getPublicKey(string $keyId): AsymmetricKey&PublicKey {
|
||||
$path = realpath(sprintf(self::KEY_FORMAT, Misuzu::PATH_CONFIG, $keyId, 'pub'));
|
||||
if($path === false)
|
||||
throw new RuntimeException(sprintf('public key "%s" could not be found', $keyId));
|
||||
|
||||
$body = file_get_contents($path);
|
||||
if($body === false)
|
||||
throw new RuntimeException(sprintf('public key "%s" could not be read', $keyId));
|
||||
|
||||
return PublicKeyLoader::loadPublicKey($body);
|
||||
}
|
||||
|
||||
/** @return array<string, AsymmetricKey&PublicKey> */
|
||||
public function getPublicKeys(): array {
|
||||
$keys = [];
|
||||
foreach($this->keyIds as $keyId)
|
||||
$keys[$keyId] = $this->getPublicKey($keyId);
|
||||
|
||||
return $keys;
|
||||
}
|
||||
|
||||
public function getPublicJWTKeySet(): JWTKeySet {
|
||||
$keys = [];
|
||||
foreach($this->keyIds as $keyId) {
|
||||
$key = $this->getPublicKey($keyId);
|
||||
$keys[$keyId] = new SecLibJWTKey(SecLibJWTKey::inferAlgorithm($key), $key);
|
||||
}
|
||||
|
||||
return new ArrayJWTKeySet($keys);
|
||||
}
|
||||
|
||||
/** @return array<string, mixed[]> */
|
||||
public function getPublicJWTsForJson(): array {
|
||||
$keys = [];
|
||||
|
||||
foreach($this->keyIds as $keyId) {
|
||||
$publicKey = $this->getPublicKey($keyId);
|
||||
$options = [
|
||||
'kid' => $keyId,
|
||||
'use' => 'sig',
|
||||
'alg' => SecLibJWTKey::inferAlgorithm($publicKey),
|
||||
];
|
||||
|
||||
$keys = array_merge_recursive(
|
||||
$keys,
|
||||
json_decode($publicKey->toString('JWK', $options), true)
|
||||
);
|
||||
}
|
||||
|
||||
return $keys;
|
||||
}
|
||||
|
||||
public function getFirstPrivateKey(): AsymmetricKey&PrivateKey {
|
||||
return $this->getPrivateKey($this->keyIds[0]);
|
||||
}
|
||||
|
||||
public function getPrivateKey(string $keyId): AsymmetricKey&PrivateKey {
|
||||
$path = realpath(sprintf(self::KEY_FORMAT, Misuzu::PATH_CONFIG, $keyId, 'priv'));
|
||||
if($path === false)
|
||||
throw new RuntimeException(sprintf('private key "%s" could not be found', $keyId));
|
||||
|
||||
$body = file_get_contents($path);
|
||||
if($body === false)
|
||||
throw new RuntimeException(sprintf('private key "%s" could not be read', $keyId));
|
||||
|
||||
return PublicKeyLoader::loadPrivateKey($body);
|
||||
}
|
||||
|
||||
/** @return array<string, AsymmetricKey&PrivateKey> */
|
||||
public function getPrivateKeys(): array {
|
||||
$keys = [];
|
||||
foreach($this->keyIds as $keyId)
|
||||
$keys[$keyId] = $this->getPrivateKey($keyId);
|
||||
|
||||
return $keys;
|
||||
}
|
||||
|
||||
public function getPrivateJWTKeySet(): JWTKeySet {
|
||||
$keys = [];
|
||||
foreach($this->keyIds as $keyId) {
|
||||
$key = $this->getPrivateKey($keyId);
|
||||
$keys[$keyId] = new SecLibJWTKey(SecLibJWTKey::inferAlgorithm($key), $key);
|
||||
}
|
||||
|
||||
return new ArrayJWTKeySet($keys);
|
||||
}
|
||||
|
||||
/** @return array<string, mixed[]> */
|
||||
public function getPrivateJWTsForJson(): array {
|
||||
$keys = [];
|
||||
|
||||
foreach($this->keyIds as $keyId) {
|
||||
$privateKey = $this->getPrivateKey($keyId);
|
||||
$options = [
|
||||
'kid' => $keyId,
|
||||
'use' => 'sig',
|
||||
'alg' => SecLibJWTKey::inferAlgorithm($privateKey),
|
||||
];
|
||||
|
||||
$keys = array_merge_recursive(
|
||||
$keys,
|
||||
json_decode($privateKey->toString('JWK', $options), true)
|
||||
);
|
||||
}
|
||||
|
||||
return $keys;
|
||||
}
|
||||
}
|
178
src/OpenID/OpenIDRoutes.php
Normal file
178
src/OpenID/OpenIDRoutes.php
Normal file
|
@ -0,0 +1,178 @@
|
|||
<?php
|
||||
namespace Misuzu\OpenID;
|
||||
|
||||
use RuntimeException;
|
||||
use Index\{UriBase64,XArray,XString};
|
||||
use Index\Colour\{Colour,ColourRgb};
|
||||
use Index\Http\{HttpResponseBuilder,HttpRequest};
|
||||
use Index\Http\Routing\{HttpGet,HttpOptions,RouteHandler,RouteHandlerCommon};
|
||||
use Index\Urls\{UrlFormat,UrlRegistry,UrlSource,UrlSourceCommon};
|
||||
use Misuzu\{Misuzu,SiteInfo};
|
||||
use Misuzu\Auth\AuthInfo;
|
||||
use Misuzu\OAuth2\{OAuth2AccessInfoGetField,OAuth2Context};
|
||||
use Misuzu\Profile\ProfileContext;
|
||||
use Misuzu\Users\UsersContext;
|
||||
|
||||
class OpenIDRoutes implements RouteHandler, UrlSource {
|
||||
use RouteHandlerCommon, UrlSourceCommon;
|
||||
|
||||
public function __construct(
|
||||
private OpenIDContext $openIdCtx,
|
||||
private OAuth2Context $oauth2Ctx,
|
||||
private UsersContext $usersCtx,
|
||||
private ProfileContext $profileCtx,
|
||||
private SiteInfo $siteInfo,
|
||||
private AuthInfo $authInfo,
|
||||
private UrlRegistry $urls
|
||||
) {}
|
||||
|
||||
#[HttpOptions('/.well-known/openid-configuration')]
|
||||
#[HttpGet('/.well-known/openid-configuration')]
|
||||
public function getWellKnown(HttpResponseBuilder $response, HttpRequest $request): array|int {
|
||||
$response->setHeader('Access-Control-Allow-Origin', '*');
|
||||
if($request->method === 'OPTIONS')
|
||||
return 204;
|
||||
|
||||
$signingAlgs = array_values(array_unique(XArray::select($this->openIdCtx->getPublicJWTKeySet()->keys, fn($key) => $key->algo)));
|
||||
sort($signingAlgs);
|
||||
|
||||
return [
|
||||
'issuer' => $this->siteInfo->url,
|
||||
'authorization_endpoint' => sprintf('%s%s', $this->siteInfo->url, $this->urls->format('oauth2-authorise')),
|
||||
'token_endpoint' => sprintf('%s%s', $this->siteInfo->url, $this->urls->format('oauth2-token')),
|
||||
'userinfo_endpoint' => sprintf('%s%s', $this->siteInfo->url, $this->urls->format('openid-userinfo')),
|
||||
'jwks_uri' => sprintf('%s%s', $this->siteInfo->url, $this->urls->format('openid-jwks')),
|
||||
'response_types_supported' => ['code', 'code id_token'],
|
||||
'response_modes_supported' => ['query'],
|
||||
'grant_types_supported' => ['authorization_code'],
|
||||
'subject_types_supported' => ['public'],
|
||||
'id_token_signing_alg_values_supported' => $signingAlgs,
|
||||
'token_endpoint_auth_methods_supported' => ['client_secret_basic', 'client_secret_post'],
|
||||
];
|
||||
}
|
||||
|
||||
#[HttpOptions('/openid/userinfo')]
|
||||
#[HttpGet('/openid/userinfo')]
|
||||
#[UrlFormat('openid-userinfo', '/openid/userinfo')]
|
||||
public function getUserInfo(HttpResponseBuilder $response, HttpRequest $request): int|array {
|
||||
$response->setHeader('Access-Control-Allow-Origin', '*');
|
||||
$response->setHeader('Access-Control-Allow-Headers', 'Authorization');
|
||||
if($request->method === 'OPTIONS')
|
||||
return 204;
|
||||
|
||||
$header = explode(' ', (string)$request->getHeaderLine('Authorization'), 2);
|
||||
if(strcasecmp($header[0], 'bearer') !== 0 || empty($header[1])) {
|
||||
$response->statusCode = 401;
|
||||
$response->setHeader('WWW-Authenticate', 'Bearer error="invalid_token", error_description="Bearer authentication must be used."');
|
||||
return [
|
||||
'error' => 'invalid_token',
|
||||
'error_description' => 'Bearer authentication must be used.',
|
||||
];
|
||||
}
|
||||
|
||||
try {
|
||||
$tokenInfo = $this->oauth2Ctx->tokens->getAccessInfo(trim($header[1]), OAuth2AccessInfoGetField::Token);
|
||||
} catch(RuntimeException $ex) {
|
||||
$tokenInfo = null;
|
||||
}
|
||||
if($tokenInfo === null || $tokenInfo->userId === null || $tokenInfo->expired) {
|
||||
$response->statusCode = 401;
|
||||
$response->setHeader('WWW-Authenticate', 'Bearer error="invalid_token", error_description="Access token has expired."');
|
||||
return [
|
||||
'error' => 'invalid_token',
|
||||
'error_description' => 'Access token has expired.',
|
||||
];
|
||||
}
|
||||
|
||||
$scopes = $tokenInfo->scopes;
|
||||
if(!in_array('openid', $scopes)) {
|
||||
$response->statusCode = 403;
|
||||
$response->setHeader('WWW-Authenticate', 'Bearer error="insufficient_scope", error_description="openid scope is required for this endpoint."');
|
||||
return [
|
||||
'error' => 'insufficient_scope',
|
||||
'error_description' => 'openid scope is required for this endpoint.',
|
||||
];
|
||||
}
|
||||
|
||||
try {
|
||||
$userInfo = $this->usersCtx->getUserInfo($tokenInfo->userId);
|
||||
} catch(RuntimeException $ex) {
|
||||
$userInfo = null;
|
||||
}
|
||||
if($userInfo === null || $userInfo->deleted) {
|
||||
$response->statusCode = 401;
|
||||
$response->setHeader('WWW-Authenticate', 'Bearer error="invalid_token", error_description="Access token has expired."');
|
||||
return [
|
||||
'error' => 'invalid_token',
|
||||
'error_description' => 'Access token has expired.',
|
||||
];
|
||||
}
|
||||
|
||||
$result = ['sub' => $userInfo->id];
|
||||
|
||||
if(in_array('profile', $scopes)) {
|
||||
$result['name'] = $userInfo->name;
|
||||
$result['nickname'] = $userInfo->name;
|
||||
$result['preferred_username'] = $userInfo->name;
|
||||
$result['profile'] = sprintf('%s%s', $this->siteInfo->url, $this->urls->format('user-profile', ['user' => $userInfo->id]));
|
||||
$result['picture'] = sprintf('%s%s', $this->siteInfo->url, $this->urls->format('user-avatar', ['user' => $userInfo->id, 'res' => 200]));
|
||||
$result['zoneinfo'] = 'UTC'; // todo: let users specify this
|
||||
|
||||
$birthdateInfo = $this->usersCtx->birthdates->getUserBirthdate($userInfo);
|
||||
if($birthdateInfo !== null)
|
||||
$result['birthdate'] = $birthdateInfo->birthdate;
|
||||
|
||||
$websiteFieldId = $this->openIdCtx->websiteProfileField;
|
||||
if(!empty($websiteFieldId))
|
||||
try {
|
||||
$result['website'] = $this->profileCtx->fields->getFieldValue($websiteFieldId, $userInfo)->value;
|
||||
} catch(RuntimeException $ex) {}
|
||||
|
||||
$result['http://railgun.sh/country_code'] = $userInfo->countryCode;
|
||||
|
||||
$colour = $this->usersCtx->getUserColour($userInfo);
|
||||
if($colour->inherits) {
|
||||
$result['http://railgun.sh/colour_raw'] = null;
|
||||
$result['http://railgun.sh/colour_css'] = (string)$colour;
|
||||
} else {
|
||||
$result['http://railgun.sh/colour_raw'] = Colour::toRawRgb($colour);
|
||||
$result['http://railgun.sh/colour_css'] = (string)ColourRgb::convert($colour);
|
||||
}
|
||||
|
||||
if($this->usersCtx->hasActiveBan($userInfo)) {
|
||||
$result['http://railgun.sh/rank'] = 0;
|
||||
$result['http://railgun.sh/banned'] = true;
|
||||
$result['http://railgun.sh/roles'] = ['x-banned'];
|
||||
} else {
|
||||
$roles = XArray::select(
|
||||
$this->usersCtx->roles->getRoles(userInfo: $userInfo, hasString: true, orderByRank: true),
|
||||
fn($roleInfo) => $roleInfo->string,
|
||||
);
|
||||
|
||||
$result['http://railgun.sh/rank'] = $this->usersCtx->getUserRank($userInfo);
|
||||
if($userInfo->super)
|
||||
$result['http://railgun.sh/is_super'] = true;
|
||||
if(!empty($roles))
|
||||
$result['http://railgun.sh/roles'] = $roles;
|
||||
}
|
||||
}
|
||||
|
||||
if(in_array('email', $scopes)) {
|
||||
$result['email'] = $userInfo->emailAddress;
|
||||
$result['email_verified'] = true;
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
#[HttpOptions('/openid/jwks.json')]
|
||||
#[HttpGet('/openid/jwks.json')]
|
||||
#[UrlFormat('openid-jwks', '/openid/jwks.json')]
|
||||
public function getJwks(HttpResponseBuilder $response, HttpRequest $request): int|array {
|
||||
$response->setHeader('Access-Control-Allow-Origin', '*');
|
||||
if($request->method === 'OPTIONS')
|
||||
return 204;
|
||||
|
||||
return $this->openIdCtx->getPublicJWTsForJson();
|
||||
}
|
||||
}
|
66
src/OpenID/SecLibJWTKey.php
Normal file
66
src/OpenID/SecLibJWTKey.php
Normal file
|
@ -0,0 +1,66 @@
|
|||
<?php
|
||||
namespace Misuzu\OpenID;
|
||||
|
||||
use RuntimeException;
|
||||
use phpseclib3\Crypt\Common\{AsymmetricKey,PrivateKey,PublicKey};
|
||||
use phpseclib3\Crypt\EC\PrivateKey as ECPrivateKey;
|
||||
use phpseclib3\Crypt\EC\PublicKey as ECPublicKey;
|
||||
use phpseclib3\Crypt\RSA\PrivateKey as RSAPrivateKey;
|
||||
use phpseclib3\Crypt\RSA\PublicKey as RSAPublicKey;
|
||||
|
||||
class SecLibJWTKey implements JWTKey {
|
||||
private ?PrivateKey $privKey;
|
||||
private PublicKey $pubKey;
|
||||
private SecLibJWTKeyAlgo $algoInfo;
|
||||
|
||||
public string $algo;
|
||||
|
||||
public function __construct(
|
||||
SecLibJWTKeyAlgo|string $algo,
|
||||
#[\SensitiveParameter] (AsymmetricKey&PrivateKey)|(AsymmetricKey&PublicKey) $key
|
||||
) {
|
||||
if(is_string($algo))
|
||||
$algo = SecLibJWTKeyAlgo::default($algo);
|
||||
|
||||
$key = $algo->transform($key);
|
||||
|
||||
$this->algo = $algo->algo;
|
||||
if($key instanceof PrivateKey) {
|
||||
$this->privKey = $key;
|
||||
$this->pubKey = $key->getPublicKey();
|
||||
} else {
|
||||
$this->privKey = null;
|
||||
$this->pubKey = $key;
|
||||
}
|
||||
}
|
||||
|
||||
public function sign(string $data): string {
|
||||
if($this->privKey === null)
|
||||
throw new RuntimeException('this instance does not have a private key, it can only be used to verify');
|
||||
|
||||
return $this->privKey->sign($data);
|
||||
}
|
||||
|
||||
public function verify(string $data, string $signature): bool {
|
||||
return $this->pubKey->verify($data, $signature);
|
||||
}
|
||||
|
||||
// you probably should not rely on this
|
||||
public static function inferAlgorithm(PrivateKey|PublicKey $key): string {
|
||||
if($key instanceof RSAPrivateKey || $key instanceof RSAPublicKey)
|
||||
return 'RS256';
|
||||
|
||||
if($key instanceof ECPrivateKey || $key instanceof ECPublicKey)
|
||||
return match($key->getCurve()) {
|
||||
'secp256r1' => 'ES256',
|
||||
'secp256k1' => 'ES256K',
|
||||
'secp384r1' => 'ES384',
|
||||
'secp521r1' => 'ES512',
|
||||
'Ed25519' => 'EdDSA',
|
||||
'Ed448' => 'EdDSA',
|
||||
default => throw new RuntimeException('unknown EC curve'),
|
||||
};
|
||||
|
||||
throw new RuntimeException('unsupported asymmetric key format');
|
||||
}
|
||||
}
|
114
src/OpenID/SecLibJWTKeyAlgo.php
Normal file
114
src/OpenID/SecLibJWTKeyAlgo.php
Normal file
|
@ -0,0 +1,114 @@
|
|||
<?php
|
||||
namespace Misuzu\OpenID;
|
||||
|
||||
use InvalidArgumentException;
|
||||
use phpseclib3\Crypt\{EC,RSA};
|
||||
use phpseclib3\Crypt\Common\AsymmetricKey;
|
||||
|
||||
class SecLibJWTKeyAlgo {
|
||||
public function __construct(
|
||||
public private(set) string $algo,
|
||||
private $transformFunc
|
||||
) {
|
||||
if(!is_callable($transformFunc))
|
||||
throw new InvalidArgumentException('$transformFunc must be callable');
|
||||
}
|
||||
|
||||
public function transform(AsymmetricKey $key): AsymmetricKey {
|
||||
return ($this->transformFunc)($key);
|
||||
}
|
||||
|
||||
public static function ensureRSAKey(AsymmetricKey $key): void {
|
||||
if(!($key instanceof RSA))
|
||||
throw new InvalidArgumentException('$key must be an RSA key');
|
||||
}
|
||||
|
||||
public static function applyPKCS1Padding(AsymmetricKey $key): AsymmetricKey {
|
||||
return $key->withPadding(RSA::SIGNATURE_PKCS1);
|
||||
}
|
||||
|
||||
public static function applyPSSPadding(AsymmetricKey $key): AsymmetricKey {
|
||||
return $key->withPadding(RSA::SIGNATURE_PSS);
|
||||
}
|
||||
|
||||
public static function ensureECKey(AsymmetricKey $key): void {
|
||||
if(!($key instanceof EC))
|
||||
throw new InvalidArgumentException('$key must be an EC key');
|
||||
}
|
||||
|
||||
public static function applyIEEESignatureFormat(AsymmetricKey $key): AsymmetricKey {
|
||||
return $key->withSignatureFormat('IEEE');
|
||||
}
|
||||
|
||||
public static function default(string $algo): SecLibJWTKeyAlgo {
|
||||
return new SecLibJWTKeyAlgo($algo, match($algo) {
|
||||
// RSxxx
|
||||
'RS256' => function(AsymmetricKey $key): AsymmetricKey {
|
||||
self::ensureRSAKey($key);
|
||||
return self::applyPKCS1Padding($key)->withHash('sha256');
|
||||
},
|
||||
'RS384' => function(AsymmetricKey $key): AsymmetricKey {
|
||||
self::ensureRSAKey($key);
|
||||
return self::applyPKCS1Padding($key)->withHash('sha384');
|
||||
},
|
||||
'RS512' => function(AsymmetricKey $key): AsymmetricKey {
|
||||
self::ensureRSAKey($key);
|
||||
return self::applyPKCS1Padding($key)->withHash('sha512');
|
||||
},
|
||||
|
||||
// PSxxx
|
||||
'PS256' => function(AsymmetricKey $key): AsymmetricKey {
|
||||
self::ensureRSAKey($key);
|
||||
return self::applyPSSPadding($key)->withHash('sha256');
|
||||
},
|
||||
'PS384' => function(AsymmetricKey $key): AsymmetricKey {
|
||||
self::ensureRSAKey($key);
|
||||
return self::applyPSSPadding($key)->withHash('sha384');
|
||||
},
|
||||
'PS512' => function(AsymmetricKey $key): AsymmetricKey {
|
||||
self::ensureRSAKey($key);
|
||||
return self::applyPSSPadding($key)->withHash('sha512');
|
||||
},
|
||||
|
||||
// ESxxxy
|
||||
'ES256' => function(AsymmetricKey $key): AsymmetricKey {
|
||||
self::ensureECKey($key);
|
||||
if($key->getCurve() !== 'secp256r1')
|
||||
throw new InvalidArgumentException('curve must be secp256r1');
|
||||
|
||||
return self::applyIEEESignatureFormat($key)->withHash('sha256');
|
||||
},
|
||||
'ES256K' => function(AsymmetricKey $key): AsymmetricKey {
|
||||
self::ensureECKey($key);
|
||||
if($key->getCurve() !== 'secp256k1')
|
||||
throw new InvalidArgumentException('curve must be secp256k1');
|
||||
|
||||
return self::applyIEEESignatureFormat($key)->withHash('sha256');
|
||||
},
|
||||
'ES384' => function(AsymmetricKey $key): AsymmetricKey {
|
||||
self::ensureECKey($key);
|
||||
if($key->getCurve() !== 'secp384r1')
|
||||
throw new InvalidArgumentException('curve must be secp384r1');
|
||||
|
||||
return self::applyIEEESignatureFormat($key)->withHash('sha384');
|
||||
},
|
||||
'ES512' => function(AsymmetricKey $key): AsymmetricKey {
|
||||
self::ensureECKey($key);
|
||||
if($key->getCurve() !== 'secp521r1')
|
||||
throw new InvalidArgumentException('curve must be secp521r1');
|
||||
|
||||
return self::applyIEEESignatureFormat($key)->withHash('sha512');
|
||||
},
|
||||
|
||||
'EdDSA' => function(AsymmetricKey $key): AsymmetricKey {
|
||||
self::ensureECKey($key);
|
||||
if($key->getCurve() !== 'Ed25519' && $key->getCurve() !== 'Ed448')
|
||||
throw new InvalidArgumentException('curve must be Ed25519 or Ed448');
|
||||
|
||||
return $key;
|
||||
},
|
||||
|
||||
default => throw new InvalidArgumentException(sprintf('unsupported algorithm: "%s"', $algo)),
|
||||
});
|
||||
}
|
||||
}
|
33
src/OpenID/SingleJWTKeySet.php
Normal file
33
src/OpenID/SingleJWTKeySet.php
Normal file
|
@ -0,0 +1,33 @@
|
|||
<?php
|
||||
namespace Misuzu\OpenID;
|
||||
|
||||
use RuntimeException;
|
||||
use phpseclib3\Crypt\Common\{AsymmetricKey,PrivateKey,PublicKey};
|
||||
|
||||
class SingleJWTKeySet implements JWTKeySet {
|
||||
private JWTKey $key;
|
||||
|
||||
public array $keys {
|
||||
get => [$key];
|
||||
}
|
||||
|
||||
public function __construct(JWTKey|(AsymmetricKey&PrivateKey)|(AsymmetricKey&PublicKey)|string $key) {
|
||||
if(is_string($key))
|
||||
$this->key = new HMACJWTKeyKey(HMACJWTKeyKey::DEFAULT_ALGO, $key);
|
||||
elseif($key instanceof AsymmetricKey)
|
||||
$this->key = new SecLibJWTKey(SecLibJWTKey::inferAlgorithm($key), $key);
|
||||
else
|
||||
$this->key = $key;
|
||||
}
|
||||
|
||||
public function getKey(?string $keyId = null, ?string $alg = null): JWTKey {
|
||||
if($alg !== null && $this->key->algo !== $alg)
|
||||
throw new RuntimeException('key does not match requested algorithm');
|
||||
|
||||
return $this->key;
|
||||
}
|
||||
|
||||
public function count(): int {
|
||||
return 1;
|
||||
}
|
||||
}
|
|
@ -231,6 +231,7 @@ class UsersData {
|
|||
'search' => self::GET_USER_ID | self::GET_USER_NAME,
|
||||
'messaging' => self::GET_USER_ID | self::GET_USER_NAME,
|
||||
'login' => self::GET_USER_NAME | self::GET_USER_MAIL,
|
||||
'webfinger' => self::GET_USER_ID | self::GET_USER_NAME,
|
||||
'recovery' => self::GET_USER_MAIL,
|
||||
];
|
||||
|
||||
|
|
72
src/Users/UsersWebFingerResolver.php
Normal file
72
src/Users/UsersWebFingerResolver.php
Normal file
|
@ -0,0 +1,72 @@
|
|||
<?php
|
||||
namespace Misuzu\Users;
|
||||
|
||||
use Exception;
|
||||
use Index\Config\Config;
|
||||
use Index\Urls\UrlRegistry;
|
||||
use Misuzu\SiteInfo;
|
||||
use Misuzu\WebFinger\{
|
||||
BasicWebFingerLink,BasicWebFingerResourceInfo,
|
||||
WebFingerResolver,WebFingerResourceInfo,
|
||||
};
|
||||
|
||||
class UsersWebFingerResolver implements WebFingerResolver {
|
||||
public function __construct(
|
||||
private UsersContext $usersCtx,
|
||||
private SiteInfo $siteInfo,
|
||||
private UrlRegistry $urls,
|
||||
private Config $config
|
||||
) {}
|
||||
|
||||
public function resolve(string $uri, array $components): ?WebFingerResourceInfo {
|
||||
$acctHost = $this->config->getString('accthost');
|
||||
$getValue = null;
|
||||
|
||||
if(isset($components['scheme']) && isset($components['path'])) {
|
||||
if($components['scheme'] === 'acct') {
|
||||
$parts = explode('@', $components['path'], 2);
|
||||
if(isset($parts[1]) && $parts[1] === $acctHost)
|
||||
$getValue = $parts[0];
|
||||
} elseif($components['scheme'] === 'https' || $components['scheme'] === 'http') {
|
||||
if(isset($components['host']) && in_array($components['host'], [$acctHost, sprintf('www.%s', $acctHost)])) {
|
||||
if($components['path'] === '/profile.php' && isset($components['query'])) {
|
||||
parse_str($components['query'], $query);
|
||||
if(isset($query['u']))
|
||||
$getValue = $query['u'];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$userInfo = null;
|
||||
if($getValue !== null)
|
||||
try {
|
||||
$userInfo = $this->usersCtx->getUserInfo($getValue, 'webfinger');
|
||||
} catch(Exception $ex) {}
|
||||
|
||||
if(!($userInfo instanceof UserInfo))
|
||||
return null;
|
||||
|
||||
return new BasicWebFingerResourceInfo(
|
||||
subject: sprintf('acct:%s@%s', $userInfo->name, $acctHost),
|
||||
aliases: [
|
||||
sprintf('%s%s', $this->siteInfo->url, $this->urls->format('user-profile', ['user' => $userInfo->id]))
|
||||
],
|
||||
links: [
|
||||
new BasicWebFingerLink(
|
||||
'http://webfinger.net/rel/profile-page',
|
||||
type: 'text/html',
|
||||
href: sprintf('%s%s', $this->siteInfo->url, $this->urls->format('user-profile', ['user' => $userInfo->id])),
|
||||
),
|
||||
new BasicWebFingerLink(
|
||||
'http://webfinger.net/rel/avatar',
|
||||
href: sprintf('%s%s', $this->siteInfo->url, $this->urls->format('user-avatar', ['user' => $userInfo->id, 'res' => 200])),
|
||||
),
|
||||
new BasicWebFingerLink(
|
||||
'http://openid.net/specs/connect/1.0/issuer',
|
||||
href: $this->siteInfo->url,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
18
src/WebFinger/BasicWebFingerLink.php
Normal file
18
src/WebFinger/BasicWebFingerLink.php
Normal file
|
@ -0,0 +1,18 @@
|
|||
<?php
|
||||
namespace Misuzu\WebFinger;
|
||||
|
||||
use InvalidArgumentException;
|
||||
use Index\XArray;
|
||||
|
||||
class BasicWebFingerLink implements WebFingerLink {
|
||||
public function __construct(
|
||||
public private(set) string $relation,
|
||||
public private(set) ?string $type = null,
|
||||
public private(set) ?string $href = null,
|
||||
public private(set) array $titles = [],
|
||||
public private(set) array $properties = [],
|
||||
) {
|
||||
if(mb_trim($relation) === '')
|
||||
throw new InvalidArgumentException('$relation may not be empty');
|
||||
}
|
||||
}
|
14
src/WebFinger/BasicWebFingerProperty.php
Normal file
14
src/WebFinger/BasicWebFingerProperty.php
Normal file
|
@ -0,0 +1,14 @@
|
|||
<?php
|
||||
namespace Misuzu\WebFinger;
|
||||
|
||||
use InvalidArgumentException;
|
||||
|
||||
class BasicWebFingerProperty implements WebFingerProperty {
|
||||
public function __construct(
|
||||
public private(set) string $name,
|
||||
public private(set) ?string $value = null
|
||||
) {
|
||||
if(mb_trim($name) === '')
|
||||
throw new InvalidArgumentException('$name may not be empty');
|
||||
}
|
||||
}
|
16
src/WebFinger/BasicWebFingerResourceInfo.php
Normal file
16
src/WebFinger/BasicWebFingerResourceInfo.php
Normal file
|
@ -0,0 +1,16 @@
|
|||
<?php
|
||||
namespace Misuzu\WebFinger;
|
||||
|
||||
use InvalidArgumentException;
|
||||
|
||||
class BasicWebFingerResourceInfo implements WebFingerResourceInfo {
|
||||
public function __construct(
|
||||
public private(set) ?string $subject = null,
|
||||
public private(set) array $aliases = [],
|
||||
public private(set) array $properties = [],
|
||||
public private(set) array $links = [],
|
||||
) {
|
||||
if($subject !== null && mb_trim($subject) === '')
|
||||
throw new InvalidArgumentException('$subject may not be an empty string');
|
||||
}
|
||||
}
|
14
src/WebFinger/BasicWebFingerTitle.php
Normal file
14
src/WebFinger/BasicWebFingerTitle.php
Normal file
|
@ -0,0 +1,14 @@
|
|||
<?php
|
||||
namespace Misuzu\WebFinger;
|
||||
|
||||
use InvalidArgumentException;
|
||||
|
||||
class BasicWebFingerTitle implements WebFingerTitle {
|
||||
public function __construct(
|
||||
public private(set) string $lang,
|
||||
public private(set) string $value
|
||||
) {
|
||||
if(mb_trim($lang) === '')
|
||||
throw new InvalidArgumentException('$lang may not be empty');
|
||||
}
|
||||
}
|
16
src/WebFinger/WebFingerLink.php
Normal file
16
src/WebFinger/WebFingerLink.php
Normal file
|
@ -0,0 +1,16 @@
|
|||
<?php
|
||||
namespace Misuzu\WebFinger;
|
||||
|
||||
interface WebFingerLink {
|
||||
public string $relation { get; }
|
||||
|
||||
public ?string $type { get; }
|
||||
|
||||
public ?string $href { get; }
|
||||
|
||||
/** @var WebFingerTitle[] */
|
||||
public array $titles { get; }
|
||||
|
||||
/** @var WebFingerProperty[] */
|
||||
public array $properties { get; }
|
||||
}
|
7
src/WebFinger/WebFingerProperty.php
Normal file
7
src/WebFinger/WebFingerProperty.php
Normal file
|
@ -0,0 +1,7 @@
|
|||
<?php
|
||||
namespace Misuzu\WebFinger;
|
||||
|
||||
interface WebFingerProperty {
|
||||
public string $name { get; }
|
||||
public ?string $value { get; }
|
||||
}
|
31
src/WebFinger/WebFingerRegistry.php
Normal file
31
src/WebFinger/WebFingerRegistry.php
Normal file
|
@ -0,0 +1,31 @@
|
|||
<?php
|
||||
namespace Misuzu\WebFinger;
|
||||
|
||||
class WebFingerRegistry {
|
||||
/** @var WebFingerResolver[] */
|
||||
private array $resolvers = [];
|
||||
|
||||
public function register(WebFingerResolver $resolver): void {
|
||||
if(!in_array($resolver, $this->resolvers))
|
||||
$this->resolvers[] = $resolver;
|
||||
}
|
||||
|
||||
/** @return WebFingerResolver[] */
|
||||
public function resolve(string $uri): array {
|
||||
$results = [];
|
||||
|
||||
if($uri !== '') {
|
||||
$components = parse_url($uri);
|
||||
if(!is_array($components))
|
||||
$components = [];
|
||||
|
||||
foreach($this->resolvers as $resolver) {
|
||||
$resInfo = $resolver->resolve($uri, $components);
|
||||
if($resInfo !== null)
|
||||
$results[] = $resInfo;
|
||||
}
|
||||
}
|
||||
|
||||
return $results;
|
||||
}
|
||||
}
|
6
src/WebFinger/WebFingerResolver.php
Normal file
6
src/WebFinger/WebFingerResolver.php
Normal file
|
@ -0,0 +1,6 @@
|
|||
<?php
|
||||
namespace Misuzu\WebFinger;
|
||||
|
||||
interface WebFingerResolver {
|
||||
public function resolve(string $uri, array $components): ?WebFingerResourceInfo;
|
||||
}
|
15
src/WebFinger/WebFingerResourceInfo.php
Normal file
15
src/WebFinger/WebFingerResourceInfo.php
Normal file
|
@ -0,0 +1,15 @@
|
|||
<?php
|
||||
namespace Misuzu\WebFinger;
|
||||
|
||||
interface WebFingerResourceInfo {
|
||||
public ?string $subject { get; } // only the authoritative answer should have this non-null
|
||||
|
||||
/** @var string[] */
|
||||
public array $aliases { get; }
|
||||
|
||||
/** @var WebFingerProperty[] */
|
||||
public array $properties { get; }
|
||||
|
||||
/** @var WebFingerLink[] */
|
||||
public array $links { get; }
|
||||
}
|
91
src/WebFinger/WebFingerRoutes.php
Normal file
91
src/WebFinger/WebFingerRoutes.php
Normal file
|
@ -0,0 +1,91 @@
|
|||
<?php
|
||||
namespace Misuzu\WebFinger;
|
||||
|
||||
use Index\XArray;
|
||||
use Index\Http\{HttpResponseBuilder,HttpRequest};
|
||||
use Index\Http\Routing\{HttpGet,HttpOptions,RouteHandler,RouteHandlerCommon};
|
||||
|
||||
class WebFingerRoutes implements RouteHandler {
|
||||
use RouteHandlerCommon;
|
||||
|
||||
public function __construct(
|
||||
private WebFingerRegistry $registry
|
||||
) {}
|
||||
|
||||
/** @param WebFingerProperty[] $propInfos */
|
||||
private static function extractProps(array $propInfos): array {
|
||||
$props = [];
|
||||
foreach($propInfos as $propInfo)
|
||||
$props[$propInfo->name] = $propInfo->value;
|
||||
return $props;
|
||||
}
|
||||
|
||||
/** @param WebFingerTitle[] $titleInfos */
|
||||
private static function extractTitles(array $titleInfos): array {
|
||||
$titles = [];
|
||||
foreach($titleInfos as $titleInfo)
|
||||
$titles[$titleInfo->lang] = $titleInfo->value;
|
||||
return $titles;
|
||||
}
|
||||
|
||||
private static function extractLinks(array $linkInfos): array {
|
||||
$links = [];
|
||||
foreach($linkInfos as $linkInfo) {
|
||||
$link = ['rel' => $linkInfo->relation];
|
||||
if($linkInfo->type !== null)
|
||||
$link['type'] = $linkInfo->type;
|
||||
if($linkInfo->href !== null)
|
||||
$link['href'] = $linkInfo->href;
|
||||
if(!empty($linkInfo->titles))
|
||||
$link['titles'] = self::extractTitles($linkInfo->titles);
|
||||
if(!empty($linkInfo->properties))
|
||||
$link['properties'] = self::extractProps($linkInfo->properties);
|
||||
$links[] = $link;
|
||||
}
|
||||
return $links;
|
||||
}
|
||||
|
||||
#[HttpOptions('/.well-known/webfinger')]
|
||||
#[HttpGet('/.well-known/webfinger')]
|
||||
public function getWebFinger(HttpResponseBuilder $response, HttpRequest $request): array|int {
|
||||
$response->setHeader('Access-Control-Allow-Origin', '*');
|
||||
if($request->method === 'OPTIONS')
|
||||
return 204;
|
||||
|
||||
$resInfos = $this->registry->resolve(trim((string)$request->getParam('resource')));
|
||||
if(empty($resInfos))
|
||||
return 404;
|
||||
|
||||
$subject = null;
|
||||
$aliases = [];
|
||||
$properties = [];
|
||||
$links = [];
|
||||
|
||||
foreach($resInfos as $resInfo) {
|
||||
if($resInfo->subject !== null && $subject === null)
|
||||
$subject = $resInfo->subject;
|
||||
if(!empty($resInfo->aliases))
|
||||
$aliases = array_unique(array_merge($aliases, $resInfo->aliases));
|
||||
if(!empty($resInfo->properties))
|
||||
$properties = array_merge($properties, self::extractProps($resInfo->properties));
|
||||
if(!empty($resInfo->links))
|
||||
$links = array_merge($links, self::extractLinks($resInfo->links));
|
||||
}
|
||||
|
||||
$result = [];
|
||||
if($subject !== null)
|
||||
$result['subject'] = $subject;
|
||||
if(!empty($aliases))
|
||||
$result['aliases'] = $aliases;
|
||||
if(!empty($properties))
|
||||
$result['properties'] = $properties;
|
||||
if(!empty($links))
|
||||
$result['links'] = $links;
|
||||
|
||||
if(empty($result))
|
||||
return 404;
|
||||
|
||||
$response->setContentType('application/jrd+json');
|
||||
return $result;
|
||||
}
|
||||
}
|
7
src/WebFinger/WebFingerTitle.php
Normal file
7
src/WebFinger/WebFingerTitle.php
Normal file
|
@ -0,0 +1,7 @@
|
|||
<?php
|
||||
namespace Misuzu\WebFinger;
|
||||
|
||||
interface WebFingerTitle {
|
||||
public string $lang { get; }
|
||||
public string $value { get; }
|
||||
}
|
Loading…
Add table
Reference in a new issue