From aaba24894c2f07de348588000001180f59255861 Mon Sep 17 00:00:00 2001 From: flashwave Date: Tue, 25 Feb 2025 02:29:51 +0000 Subject: [PATCH] Added OpenID Connect. --- .gitignore | 1 + VERSION | 2 +- assets/oauth2.js/authorise.js | 21 +- composer.json | 3 +- composer.lock | 360 +++++++++++++------ public/images/flashii-colour.svg | 1 + src/MisuzuContext.php | 11 + src/OAuth2/OAuth2ApiRoutes.php | 9 +- src/OAuth2/OAuth2Context.php | 16 +- src/OAuth2/OAuth2WebRoutes.php | 38 +- src/OpenID/ArrayJWTKeySet.php | 71 ++++ src/OpenID/HMACJWTKey.php | 33 ++ src/OpenID/JWT.php | 103 ++++++ src/OpenID/JWTKey.php | 12 + src/OpenID/JWTKeySet.php | 13 + src/OpenID/OpenIDContext.php | 178 +++++++++ src/OpenID/OpenIDRoutes.php | 178 +++++++++ src/OpenID/SecLibJWTKey.php | 66 ++++ src/OpenID/SecLibJWTKeyAlgo.php | 114 ++++++ src/OpenID/SingleJWTKeySet.php | 33 ++ src/Users/UsersData.php | 1 + src/Users/UsersWebFingerResolver.php | 72 ++++ src/WebFinger/BasicWebFingerLink.php | 18 + src/WebFinger/BasicWebFingerProperty.php | 14 + src/WebFinger/BasicWebFingerResourceInfo.php | 16 + src/WebFinger/BasicWebFingerTitle.php | 14 + src/WebFinger/WebFingerLink.php | 16 + src/WebFinger/WebFingerProperty.php | 7 + src/WebFinger/WebFingerRegistry.php | 31 ++ src/WebFinger/WebFingerResolver.php | 6 + src/WebFinger/WebFingerResourceInfo.php | 15 + src/WebFinger/WebFingerRoutes.php | 91 +++++ src/WebFinger/WebFingerTitle.php | 7 + 33 files changed, 1440 insertions(+), 131 deletions(-) create mode 100644 public/images/flashii-colour.svg create mode 100644 src/OpenID/ArrayJWTKeySet.php create mode 100644 src/OpenID/HMACJWTKey.php create mode 100644 src/OpenID/JWT.php create mode 100644 src/OpenID/JWTKey.php create mode 100644 src/OpenID/JWTKeySet.php create mode 100644 src/OpenID/OpenIDContext.php create mode 100644 src/OpenID/OpenIDRoutes.php create mode 100644 src/OpenID/SecLibJWTKey.php create mode 100644 src/OpenID/SecLibJWTKeyAlgo.php create mode 100644 src/OpenID/SingleJWTKeySet.php create mode 100644 src/Users/UsersWebFingerResolver.php create mode 100644 src/WebFinger/BasicWebFingerLink.php create mode 100644 src/WebFinger/BasicWebFingerProperty.php create mode 100644 src/WebFinger/BasicWebFingerResourceInfo.php create mode 100644 src/WebFinger/BasicWebFingerTitle.php create mode 100644 src/WebFinger/WebFingerLink.php create mode 100644 src/WebFinger/WebFingerProperty.php create mode 100644 src/WebFinger/WebFingerRegistry.php create mode 100644 src/WebFinger/WebFingerResolver.php create mode 100644 src/WebFinger/WebFingerResourceInfo.php create mode 100644 src/WebFinger/WebFingerRoutes.php create mode 100644 src/WebFinger/WebFingerTitle.php diff --git a/.gitignore b/.gitignore index 706011ad..8369882e 100644 --- a/.gitignore +++ b/.gitignore @@ -16,6 +16,7 @@ /config/github.cfg /config/config.ini /config/github.ini +/config/keys/*.pem /.debug /.migrating diff --git a/VERSION b/VERSION index efb19b96..8781321a 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -20250220 +20250225 diff --git a/assets/oauth2.js/authorise.js b/assets/oauth2.js/authorise.js index 628e76f8..219de4f0 100644 --- a/assets/oauth2.js/authorise.js +++ b/assets/oauth2.js/authorise.js @@ -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()); diff --git a/composer.json b/composer.json index f05be536..084e13d4 100644 --- a/composer.json +++ b/composer.json @@ -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": [ diff --git a/composer.lock b/composer.lock index ee8ee9c4..8d96b2ee 100644 --- a/composer.lock +++ b/composer.lock @@ -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": [], diff --git a/public/images/flashii-colour.svg b/public/images/flashii-colour.svg new file mode 100644 index 00000000..cc465052 --- /dev/null +++ b/public/images/flashii-colour.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/MisuzuContext.php b/src/MisuzuContext.php index 270a8d5c..43a72bae 100644 --- a/src/MisuzuContext.php +++ b/src/MisuzuContext.php @@ -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)); diff --git a/src/OAuth2/OAuth2ApiRoutes.php b/src/OAuth2/OAuth2ApiRoutes.php index fe2242a5..1bcc0a3c 100644 --- a/src/OAuth2/OAuth2ApiRoutes.php +++ b/src/OAuth2/OAuth2ApiRoutes.php @@ -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'); diff --git a/src/OAuth2/OAuth2Context.php b/src/OAuth2/OAuth2Context.php index 84ed7e96..5c1e02b7 100644 --- a/src/OAuth2/OAuth2Context.php +++ b/src/OAuth2/OAuth2Context.php @@ -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); } } diff --git a/src/OAuth2/OAuth2WebRoutes.php b/src/OAuth2/OAuth2WebRoutes.php index 94285413..aa06e1ee 100644 --- a/src/OAuth2/OAuth2WebRoutes.php +++ b/src/OAuth2/OAuth2WebRoutes.php @@ -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 diff --git a/src/OpenID/ArrayJWTKeySet.php b/src/OpenID/ArrayJWTKeySet.php new file mode 100644 index 00000000..772ceed3 --- /dev/null +++ b/src/OpenID/ArrayJWTKeySet.php @@ -0,0 +1,71 @@ + $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); + } +} diff --git a/src/OpenID/HMACJWTKey.php b/src/OpenID/HMACJWTKey.php new file mode 100644 index 00000000..bdf22477 --- /dev/null +++ b/src/OpenID/HMACJWTKey.php @@ -0,0 +1,33 @@ + '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); + } +} diff --git a/src/OpenID/JWT.php b/src/OpenID/JWT.php new file mode 100644 index 00000000..219d7ae8 --- /dev/null +++ b/src/OpenID/JWT.php @@ -0,0 +1,103 @@ + '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; + } +} diff --git a/src/OpenID/JWTKey.php b/src/OpenID/JWTKey.php new file mode 100644 index 00000000..8cdcee60 --- /dev/null +++ b/src/OpenID/JWTKey.php @@ -0,0 +1,12 @@ + */ + public array $keys { get; } + + /** @throws RuntimeException if no match could be found */ + public function getKey(?string $keyId = null, ?string $alg = null): JWTKey; +} diff --git a/src/OpenID/OpenIDContext.php b/src/OpenID/OpenIDContext.php new file mode 100644 index 00000000..b674e930 --- /dev/null +++ b/src/OpenID/OpenIDContext.php @@ -0,0 +1,178 @@ + $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 */ + 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 */ + 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 */ + 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 */ + 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; + } +} diff --git a/src/OpenID/OpenIDRoutes.php b/src/OpenID/OpenIDRoutes.php new file mode 100644 index 00000000..deffcb93 --- /dev/null +++ b/src/OpenID/OpenIDRoutes.php @@ -0,0 +1,178 @@ +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(); + } +} diff --git a/src/OpenID/SecLibJWTKey.php b/src/OpenID/SecLibJWTKey.php new file mode 100644 index 00000000..e44a3188 --- /dev/null +++ b/src/OpenID/SecLibJWTKey.php @@ -0,0 +1,66 @@ +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'); + } +} diff --git a/src/OpenID/SecLibJWTKeyAlgo.php b/src/OpenID/SecLibJWTKeyAlgo.php new file mode 100644 index 00000000..d8689e4e --- /dev/null +++ b/src/OpenID/SecLibJWTKeyAlgo.php @@ -0,0 +1,114 @@ +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)), + }); + } +} diff --git a/src/OpenID/SingleJWTKeySet.php b/src/OpenID/SingleJWTKeySet.php new file mode 100644 index 00000000..04f2eb44 --- /dev/null +++ b/src/OpenID/SingleJWTKeySet.php @@ -0,0 +1,33 @@ + [$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; + } +} diff --git a/src/Users/UsersData.php b/src/Users/UsersData.php index fc436743..c53315c9 100644 --- a/src/Users/UsersData.php +++ b/src/Users/UsersData.php @@ -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, ]; diff --git a/src/Users/UsersWebFingerResolver.php b/src/Users/UsersWebFingerResolver.php new file mode 100644 index 00000000..095ec5bf --- /dev/null +++ b/src/Users/UsersWebFingerResolver.php @@ -0,0 +1,72 @@ +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, + ), + ], + ); + } +} diff --git a/src/WebFinger/BasicWebFingerLink.php b/src/WebFinger/BasicWebFingerLink.php new file mode 100644 index 00000000..255a351c --- /dev/null +++ b/src/WebFinger/BasicWebFingerLink.php @@ -0,0 +1,18 @@ +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; + } +} diff --git a/src/WebFinger/WebFingerResolver.php b/src/WebFinger/WebFingerResolver.php new file mode 100644 index 00000000..ff45ed2c --- /dev/null +++ b/src/WebFinger/WebFingerResolver.php @@ -0,0 +1,6 @@ +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; + } +} diff --git a/src/WebFinger/WebFingerTitle.php b/src/WebFinger/WebFingerTitle.php new file mode 100644 index 00000000..a187dec4 --- /dev/null +++ b/src/WebFinger/WebFingerTitle.php @@ -0,0 +1,7 @@ +