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