Added OpenID Connect.

This commit is contained in:
flash 2025-02-25 02:29:51 +00:00
parent 24d93a5dbf
commit aaba24894c
33 changed files with 1440 additions and 131 deletions

1
.gitignore vendored
View file

@ -16,6 +16,7 @@
/config/github.cfg
/config/config.ini
/config/github.ini
/config/keys/*.pem
/.debug
/.migrating

View file

@ -1 +1 @@
20250220
20250225

View file

@ -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());

View file

@ -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
View file

@ -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": [],

View 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

View file

@ -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));

View file

@ -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');

View file

@ -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);
}
}

View file

@ -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

View 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
View 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
View 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
View 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
View 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;
}

View 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
View 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();
}
}

View 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');
}
}

View 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)),
});
}
}

View 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;
}
}

View file

@ -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,
];

View 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,
),
],
);
}
}

View 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');
}
}

View 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');
}
}

View 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');
}
}

View 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');
}
}

View 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; }
}

View file

@ -0,0 +1,7 @@
<?php
namespace Misuzu\WebFinger;
interface WebFingerProperty {
public string $name { get; }
public ?string $value { get; }
}

View 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;
}
}

View file

@ -0,0 +1,6 @@
<?php
namespace Misuzu\WebFinger;
interface WebFingerResolver {
public function resolve(string $uri, array $components): ?WebFingerResourceInfo;
}

View 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; }
}

View 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;
}
}

View file

@ -0,0 +1,7 @@
<?php
namespace Misuzu\WebFinger;
interface WebFingerTitle {
public string $lang { get; }
public string $value { get; }
}