From 446c675613f8358f3a433f119dfe0fb8052a52dd Mon Sep 17 00:00:00 2001 From: flashwave Date: Wed, 26 Feb 2025 21:01:32 +0000 Subject: [PATCH] JWT out of the OpenID namespace and merged the remainder with the OAuth namespace. --- .../ArrayJWKSet.php} | 31 ++- src/JWT/HMACJWK.php | 55 ++++++ src/JWT/JWK.php | 13 ++ src/JWT/JWKSet.php | 13 ++ src/{OpenID => JWT}/JWT.php | 33 +--- src/JWT/SecLibJWK.php | 51 +++++ src/JWT/SecLibJWKAlgo.php | 161 ++++++++++++++++ src/MisuzuContext.php | 3 - src/OAuth2/OAuth2ApiRoutes.php | 161 ++++++++++++++++ src/OAuth2/OAuth2Context.php | 53 +++++- src/OAuth2/OAuth2Keys.php | 96 ++++++++++ src/OAuth2/OAuth2WebRoutes.php | 4 +- src/OpenID/HMACJWTKey.php | 33 ---- 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 ---- 20 files changed, 622 insertions(+), 679 deletions(-) rename src/{OpenID/ArrayJWTKeySet.php => JWT/ArrayJWKSet.php} (66%) create mode 100644 src/JWT/HMACJWK.php create mode 100644 src/JWT/JWK.php create mode 100644 src/JWT/JWKSet.php rename src/{OpenID => JWT}/JWT.php (77%) create mode 100644 src/JWT/SecLibJWK.php create mode 100644 src/JWT/SecLibJWKAlgo.php create mode 100644 src/OAuth2/OAuth2Keys.php delete mode 100644 src/OpenID/HMACJWTKey.php delete mode 100644 src/OpenID/JWTKey.php delete mode 100644 src/OpenID/JWTKeySet.php delete mode 100644 src/OpenID/OpenIDContext.php delete mode 100644 src/OpenID/OpenIDRoutes.php delete mode 100644 src/OpenID/SecLibJWTKey.php delete mode 100644 src/OpenID/SecLibJWTKeyAlgo.php delete mode 100644 src/OpenID/SingleJWTKeySet.php diff --git a/src/OpenID/ArrayJWTKeySet.php b/src/JWT/ArrayJWKSet.php similarity index 66% rename from src/OpenID/ArrayJWTKeySet.php rename to src/JWT/ArrayJWKSet.php index 772ceed3..e882ab48 100644 --- a/src/OpenID/ArrayJWTKeySet.php +++ b/src/JWT/ArrayJWKSet.php @@ -1,23 +1,21 @@ $keys */ +class ArrayJWKSet implements JWKSet { + /** @param array $keys */ public function __construct(public private(set) array $keys) {} - public function getKey(?string $keyId = null, ?string $alg = null): JWTKey { + public function getKey(?string $keyId = null, ?string $algo = null): JWK { if($keyId === null) { - if($alg === null) + if($algo === null) return $this->keys[array_key_first($this->keys)]; foreach($this->keys as $key) - if(hash_equals($key->algo, $alg)) + if($algo !== null && $key->algo !== null && hash_equals($key->algo, $algo)) return $key; throw new RuntimeException('could not find a key that matched the requested algorithm'); @@ -27,17 +25,13 @@ class ArrayJWTKeySet implements JWTKeySet { throw new RuntimeException('could not find a key with that id'); $key = $this->keys[$keyId]; - if($alg !== null && !hash_equals($key->algo, $alg)) + if($algo !== null && $key->algo !== null && !hash_equals($key->algo, $algo)) 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 { + public static function decodeJson(string|array|object $json): ArrayJWKSet { if(is_object($json)) $json = (array)$json; elseif(is_string($json)) @@ -47,7 +41,7 @@ class ArrayJWTKeySet implements JWTKeySet { $json = (object)$json; if(!property_exists($json, 'keys') || !is_array($json->keys) || !array_is_list($json->keys)) - return new ArrayJWTKeySet([]); + return new ArrayJWKSet([]); $keys = []; @@ -55,10 +49,7 @@ class ArrayJWTKeySet implements JWTKeySet { $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 - ); + $key = new SecLibJWK($key, property_exists($keyInfo, 'alg') && is_string($keyInfo->alg) ? $keyInfo->alg : null); if(property_exists($keyInfo, 'kid') && is_string($keyInfo->kid)) $keys[$keyInfo->kid] = $key; @@ -66,6 +57,6 @@ class ArrayJWTKeySet implements JWTKeySet { $keys[] = $key; } - return new ArrayJWTKeySet($keys); + return new ArrayJWKSet($keys); } } diff --git a/src/JWT/HMACJWK.php b/src/JWT/HMACJWK.php new file mode 100644 index 00000000..411cf393 --- /dev/null +++ b/src/JWT/HMACJWK.php @@ -0,0 +1,55 @@ + */ + private static array $algos = []; + + public function __construct( + #[\SensitiveParameter] private string $key, + public private(set) ?string $algo = null, + public private(set) ?string $id = null, + ) {} + + public function sign(string $data, ?string $algo = null): string { + if($algo === null) { + if($this->algo === null) + throw new InvalidArgumentException('this key does not specify a default algorithm, you must specify $algo'); + + $algo = $this->algo; + } + + if(!array_key_exists($algo, self::$algos)) + throw new InvalidArgumentException('$algo is not a supported algorithm'); + + return hash_hmac(self::$algos[$algo], $data, $this->key, true); + } + + public function verify(string $data, string $signature, ?string $algo = null): bool { + return hash_equals($this->sign($data, $algo), $signature); + } + + /** @return string[] */ + public static function algos(): array { + return array_keys(self::$algos); + } + + public static function defineAlgo(string $alias, string $algo): void { + if(array_key_exists($alias, self::$algos)) + throw new InvalidArgumentException('$alias has already been defined'); + if(!in_array($algo, hash_hmac_algos())) + throw new InvalidArgumentException('$algo is not a supported HMAC hashing algorithm'); + + self::$algos[$alias] = $algo; + } + + public static function init(): void { + self::defineAlgo('HS256', 'sha256'); + self::defineAlgo('HS384', 'sha384'); + self::defineAlgo('HS512', 'sha512'); + } +} + +HMACJWK::init(); diff --git a/src/JWT/JWK.php b/src/JWT/JWK.php new file mode 100644 index 00000000..f0f3950d --- /dev/null +++ b/src/JWT/JWK.php @@ -0,0 +1,13 @@ + */ + public array $keys { get; } + + /** @throws RuntimeException if no match could be found */ + public function getKey(?string $keyId = null, ?string $algo = null): JWK; +} diff --git a/src/OpenID/JWT.php b/src/JWT/JWT.php similarity index 77% rename from src/OpenID/JWT.php rename to src/JWT/JWT.php index 219d7ae8..35f3084d 100644 --- a/src/OpenID/JWT.php +++ b/src/JWT/JWT.php @@ -1,42 +1,37 @@ 'JWT']; if($header !== null) $head = array_merge($head, (array)$header); - $head['alg'] = $key->algo; - if($keyId !== null) - $head['kid'] = $keyId; + $head['alg'] = $alg ?? $key->algo; + if($head['alg'] === null) + throw new InvalidArgumentException('$key does not provide a default algorithm, $alg must be specified'); + if($key->id !== null) + $head['kid'] = $key->id; $encoded = sprintf('%s.%s', UriBase64::encode(json_encode($head)), UriBase64::encode(json_encode($payload))); - $encoded = sprintf('%s.%s', $encoded, UriBase64::encode($key->sign($encoded))); + $encoded = sprintf('%s.%s', $encoded, UriBase64::encode($key->sign($encoded, $alg))); return $encoded; } public static function decode( string $token, - array|JWTKeySet|JWTKey|(AsymmetricKey&PrivateKey)|(AsymmetricKey&PublicKey)|string $keys, + JWKSet $keys, array|object|null $headers = null, ?int $timestamp = null, int $leeway = 0, @@ -44,14 +39,6 @@ class JWT { ): 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'); @@ -80,7 +67,7 @@ class JWT { $header['alg'] ); - if(!$key->verify(sprintf('%s.%s', $headerEnc, $payloadEnc), $sig)) + if(!$key->verify(sprintf('%s.%s', $headerEnc, $payloadEnc), $sig, $header['alg'])) throw new RuntimeException('signature verification failed'); if(array_key_exists('nbf', $payload) && (is_int($payload['nbf']) || is_float($payload['nbf']))) { diff --git a/src/JWT/SecLibJWK.php b/src/JWT/SecLibJWK.php new file mode 100644 index 00000000..2151c9d8 --- /dev/null +++ b/src/JWT/SecLibJWK.php @@ -0,0 +1,51 @@ + $this->algoInfo?->algo; + } + + public function __construct( + #[\SensitiveParameter] private (AsymmetricKey&PrivateKey)|(AsymmetricKey&PublicKey) $key, + SecLibJWKAlgo|string|null $algo = null, + public private(set) ?string $id = null, + ) { + if(is_string($algo)) + $algo = SecLibJWKAlgo::create($algo); + + $this->algoInfo = $algo; + } + + private function resolveAlgo(SecLibJWKAlgo|string|null $algo): SecLibJWKAlgo { + if($algo === null) { + if($this->algoInfo === null) + throw new InvalidArgumentException('this key does not specify a default algorithm, you must specify $algo'); + + $algo = $this->algoInfo; + } elseif(is_string($algo)) + $algo = SecLibJWKAlgo::create($algo); + + return $algo; + } + + public function sign(string $data, SecLibJWKAlgo|string|null $algo = null): string { + if(!($this->key instanceof PrivateKey)) + throw new RuntimeException('this instance does not have a private key, it can only be used to verify'); + + return $this->resolveAlgo($algo)->transform($this->key)->sign($data); + } + + public function verify(string $data, string $signature, SecLibJWKAlgo|string|null $algo = null): bool { + $key = $this->resolveAlgo($algo)->transform($this->key); + if($key instanceof PrivateKey) + $key = $key->getPublicKey(); + + return $key->verify($data, $signature); + } +} diff --git a/src/JWT/SecLibJWKAlgo.php b/src/JWT/SecLibJWKAlgo.php new file mode 100644 index 00000000..f4c210f9 --- /dev/null +++ b/src/JWT/SecLibJWKAlgo.php @@ -0,0 +1,161 @@ + */ + private static array $algos = []; + + /** @param callable(AsymmetricKey): AsymmetricKey $transformFunc */ + public function __construct( + public private(set) string $algo, + private $transformFunc + ) { + if(!is_callable($transformFunc)) + throw new InvalidArgumentException('$transformFunc must be callable'); + } + + public function transform(AsymmetricKey $key): AsymmetricKey { + return ($this->transformFunc)($key); + } + + public static function create(string $algo): SecLibJWKAlgo { + if(!array_key_exists($algo, self::$algos)) + throw new InvalidArgumentException(sprintf('$algo is not a supported algorithm: "%s"', $algo)); + + return new SecLibJWKAlgo($algo, self::$algos[$algo]); + } + + 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'); + } + + /** @return string[] */ + public static function algos(): array { + return array_keys(self::$algos); + } + + public static function transformRS256(AsymmetricKey $key): AsymmetricKey { + self::ensureRSAKey($key); + return self::applyPKCS1Padding($key)->withHash('sha256'); + } + + public static function transformRS384(AsymmetricKey $key): AsymmetricKey { + self::ensureRSAKey($key); + return self::applyPKCS1Padding($key)->withHash('sha384'); + } + + public static function transformRS512(AsymmetricKey $key): AsymmetricKey { + self::ensureRSAKey($key); + return self::applyPKCS1Padding($key)->withHash('sha512'); + } + + public static function transformPS256(AsymmetricKey $key): AsymmetricKey { + self::ensureRSAKey($key); + return self::applyPSSPadding($key)->withHash('sha512'); + } + + public static function transformPS384(AsymmetricKey $key): AsymmetricKey { + self::ensureRSAKey($key); + return self::applyPSSPadding($key)->withHash('sha512'); + } + + public static function transformPS512(AsymmetricKey $key): AsymmetricKey { + self::ensureRSAKey($key); + return self::applyPSSPadding($key)->withHash('sha512'); + } + + public static function transformES256(AsymmetricKey $key): AsymmetricKey { + self::ensureECKey($key); + if($key->getCurve() !== 'secp256r1') + throw new InvalidArgumentException('curve must be secp256r1'); + + return self::applyIEEESignatureFormat($key)->withHash('sha256'); + } + + public static function transformES256K(AsymmetricKey $key): AsymmetricKey { + self::ensureECKey($key); + if($key->getCurve() !== 'secp256k1') + throw new InvalidArgumentException('curve must be secp256k1'); + + return self::applyIEEESignatureFormat($key)->withHash('sha256'); + } + + public static function transformES384(AsymmetricKey $key): AsymmetricKey { + self::ensureECKey($key); + if($key->getCurve() !== 'secp384r1') + throw new InvalidArgumentException('curve must be secp384r1'); + + return self::applyIEEESignatureFormat($key)->withHash('sha384'); + } + + public static function transformES512(AsymmetricKey $key): AsymmetricKey { + self::ensureECKey($key); + if($key->getCurve() !== 'secp521r1') + throw new InvalidArgumentException('curve must be secp521r1'); + + return self::applyIEEESignatureFormat($key)->withHash('sha512'); + } + + public static function transformEdDSA(AsymmetricKey $key): AsymmetricKey { + self::ensureECKey($key); + if($key->getCurve() !== 'Ed25519' && $key->getCurve() !== 'Ed448') + throw new InvalidArgumentException('curve must be Ed25519 or Ed448'); + + return $key; + } + + /** @param callable(AsymmetricKey): AsymmetricKey $transformFunc */ + public static function defineAlgo(string $algo, $transformFunc): void { + if(!is_callable($transformFunc)) + throw new InvalidArgumentException('$transformFunc must be callable'); + if(array_key_exists($algo, self::$algos)) + throw new InvalidArgumentException('$algo has already been defined'); + + self::$algos[$algo] = $transformFunc; + } + + public static function init(): void { + // RSxxx + self::defineAlgo('RS256', self::transformRS256(...)); + self::defineAlgo('RS384', self::transformRS384(...)); + self::defineAlgo('RS512', self::transformRS512(...)); + + // PSxxx + self::defineAlgo('PS256', self::transformPS256(...)); + self::defineAlgo('PS384', self::transformPS384(...)); + self::defineAlgo('PS512', self::transformPS512(...)); + + // ESxxxy + self::defineAlgo('ES256', self::transformES256(...)); + self::defineAlgo('ES256K', self::transformES256K(...)); + self::defineAlgo('ES384', self::transformES384(...)); + self::defineAlgo('ES512', self::transformES512(...)); + + // EdDSA + self::defineAlgo('EdDSA', self::transformEdDSA(...)); + } +} + +SecLibJWKAlgo::init(); diff --git a/src/MisuzuContext.php b/src/MisuzuContext.php index 43a72bae..46475fac 100644 --- a/src/MisuzuContext.php +++ b/src/MisuzuContext.php @@ -35,7 +35,6 @@ 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; @@ -73,7 +72,6 @@ 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'))); @@ -192,7 +190,6 @@ 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)); diff --git a/src/OAuth2/OAuth2ApiRoutes.php b/src/OAuth2/OAuth2ApiRoutes.php index b4df9e9d..3d201e68 100644 --- a/src/OAuth2/OAuth2ApiRoutes.php +++ b/src/OAuth2/OAuth2ApiRoutes.php @@ -8,6 +8,8 @@ use Index\Http\Routing\{HttpGet,HttpOptions,HttpPost,RouteHandler,RouteHandlerCo use Index\Urls\{UrlFormat,UrlRegistry,UrlSource,UrlSourceCommon}; use Misuzu\SiteInfo; use Misuzu\Apps\AppsContext; +use Misuzu\Profile\ProfileContext; +use Misuzu\Users\UsersContext; final class OAuth2ApiRoutes implements RouteHandler, UrlSource { use RouteHandlerCommon, UrlSourceCommon; @@ -15,6 +17,8 @@ final class OAuth2ApiRoutes implements RouteHandler, UrlSource { public function __construct( private OAuth2Context $oauth2Ctx, private AppsContext $appsCtx, + private UsersContext $usersCtx, + private ProfileContext $profileCtx, private UrlRegistry $urls, private SiteInfo $siteInfo, ) {} @@ -100,6 +104,47 @@ final class OAuth2ApiRoutes implements RouteHandler, UrlSource { ]; } + #[HttpOptions('/.well-known/openid-configuration')] + #[HttpGet('/.well-known/openid-configuration')] + public function getWellKnown(HttpResponseBuilder $response, HttpRequest $request): array|int { + $response->setHeader('Access-Control-Allow-Origin', '*'); + if($request->method === 'OPTIONS') + return 204; + + $signingAlgs = array_values(array_unique(XArray::select( + $this->oauth2Ctx->keys->getPublicKeySet()->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('oauth2-openid-userinfo')), + 'jwks_uri' => sprintf('%s%s', $this->siteInfo->url, $this->urls->format('oauth2-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('/oauth2/jwks.json')] + #[HttpOptions('/openid/jwks.json')] + #[HttpGet('/oauth2/jwks.json')] + #[HttpGet('/openid/jwks.json')] + #[UrlFormat('oauth2-jwks', '/oauth2/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->oauth2Ctx->keys->getPublicKeysForJson(); + } + /** * @return array{ * device_code: string, @@ -278,4 +323,120 @@ final class OAuth2ApiRoutes implements RouteHandler, UrlSource { 'error_description' => 'Requested grant type is not supported by this server.', ]); } + + #[HttpOptions('/oauth2/userinfo')] + #[HttpOptions('/openid/userinfo')] + #[HttpGet('/oauth2/userinfo')] + #[HttpGet('/openid/userinfo')] + #[UrlFormat('oauth2-openid-userinfo', '/oauth2/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->oauth2Ctx->userInfoWebsiteProfileField; + 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; + } } diff --git a/src/OAuth2/OAuth2Context.php b/src/OAuth2/OAuth2Context.php index 5c1e02b7..5fee4744 100644 --- a/src/OAuth2/OAuth2Context.php +++ b/src/OAuth2/OAuth2Context.php @@ -4,24 +4,31 @@ namespace Misuzu\OAuth2; use RuntimeException; use Index\Config\Config; use Index\Db\DbConnection; +use Misuzu\SiteInfo; use Misuzu\Apps\{AppsContext,AppInfo}; -use Misuzu\OpenID\OpenIDContext; +use Misuzu\JWT\JWT; use Misuzu\Users\UserInfo; class OAuth2Context { public private(set) OAuth2AuthorisationData $authorisations; public private(set) OAuth2TokensData $tokens; public private(set) OAuth2DevicesData $devices; + public private(set) OAuth2Keys $keys; public function __construct( private Config $config, DbConnection $dbConn, public private(set) AppsContext $appsCtx, - private OpenIDContext $openIdCtx, + private SiteInfo $siteInfo, ) { $this->authorisations = new OAuth2AuthorisationData($dbConn); $this->tokens = new OAuth2TokensData($dbConn); $this->devices = new OAuth2DevicesData($dbConn); + $this->keys = new OAuth2Keys($config->getArray('keys')); + } + + public string $userInfoWebsiteProfileField { + get => $this->config->getString('userinfo_website_profile_field'); } /** @@ -73,6 +80,46 @@ class OAuth2Context { ); } + 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->keys->getPrivateKeySet()->getKey($keyId) + ); + } + /** @return string|array{ error: string, error_description: string } */ public function checkAndBuildScopeString( AppInfo $appInfo, @@ -169,7 +216,7 @@ class OAuth2Context { $result['refresh_token'] = $refreshInfo->token; if(in_array('openid', $accessInfo->scopes)) - $result['id_token'] = $this->openIdCtx->createIdToken($appInfo, $accessInfo); + $result['id_token'] = $this->createIdToken($appInfo, $accessInfo); return $result; } diff --git a/src/OAuth2/OAuth2Keys.php b/src/OAuth2/OAuth2Keys.php new file mode 100644 index 00000000..d74dae09 --- /dev/null +++ b/src/OAuth2/OAuth2Keys.php @@ -0,0 +1,96 @@ + */ + private array $keys; + + /** @param string[] $keyIds */ + public function __construct(array $keyIds) { + $keys = []; + foreach($keyIds as $keyId) { + $parts = explode(';', $keyId); + $key = new stdClass; + $key->id = $parts[0]; + $key->alg = $parts[1] ?? null; + $keys[$key->id] = $key; + } + + $this->keys = $keys; + } + + public function getPublicKey(string $keyId): AsymmetricKey&PublicKey { + $path = realpath(sprintf(self::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); + } + + public function getPrivateKey(string $keyId): AsymmetricKey&PrivateKey { + $path = realpath(sprintf(self::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); + } + + public function getPublicKeySet(): JWKSet { + $keys = []; + foreach($this->keys as $keyInfo) + $keys[$keyInfo->id] = new SecLibJWK( + $this->getPublicKey($keyInfo->id), + $keyInfo->alg, + $keyInfo->id + ); + + return new ArrayJWKSet($keys); + } + + public function getPrivateKeySet(): JWKSet { + $keys = []; + foreach($this->keys as $keyInfo) + $keys[$keyInfo->id] = new SecLibJWK( + $this->getPrivateKey($keyInfo->id), + $keyInfo->alg, + $keyInfo->id + ); + + return new ArrayJWKSet($keys); + } + + /** @return array */ + public function getPublicKeysForJson(): array { + $keys = []; + + foreach($this->keys as $keyInfo) + $keys = array_merge_recursive( + $keys, + json_decode($this->getPublicKey($keyInfo->id)->toString('JWK', [ + 'kid' => $keyInfo->id, + 'use' => 'sig', + 'alg' => $keyInfo->alg, + ]), true) + ); + + return $keys; + } +} diff --git a/src/OAuth2/OAuth2WebRoutes.php b/src/OAuth2/OAuth2WebRoutes.php index aa06e1ee..5e643f6e 100644 --- a/src/OAuth2/OAuth2WebRoutes.php +++ b/src/OAuth2/OAuth2WebRoutes.php @@ -9,7 +9,6 @@ use Index\Http\Routing\{HttpGet,HttpPost,RouteHandler,RouteHandlerCommon}; 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, UrlSource { @@ -20,7 +19,6 @@ final class OAuth2WebRoutes implements RouteHandler, UrlSource { public function __construct( private OAuth2Context $oauth2Ctx, - private OpenIDContext $openIdCtx, private UsersContext $usersCtx, private UrlRegistry $urls, private AuthInfo $authInfo, @@ -138,7 +136,7 @@ final class OAuth2WebRoutes implements RouteHandler, UrlSource { ]; if(in_array('id_token', $responseTypes)) - $result['id_token'] = $this->openIdCtx->createIdToken($appInfo, $authsInfo); + $result['id_token'] = $this->oauth2Ctx->createIdToken($appInfo, $authsInfo); return $result; } diff --git a/src/OpenID/HMACJWTKey.php b/src/OpenID/HMACJWTKey.php deleted file mode 100644 index bdf22477..00000000 --- a/src/OpenID/HMACJWTKey.php +++ /dev/null @@ -1,33 +0,0 @@ - '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/JWTKey.php b/src/OpenID/JWTKey.php deleted file mode 100644 index 8cdcee60..00000000 --- a/src/OpenID/JWTKey.php +++ /dev/null @@ -1,12 +0,0 @@ - */ - 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 deleted file mode 100644 index b674e930..00000000 --- a/src/OpenID/OpenIDContext.php +++ /dev/null @@ -1,178 +0,0 @@ - $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 deleted file mode 100644 index f082ef86..00000000 --- a/src/OpenID/OpenIDRoutes.php +++ /dev/null @@ -1,178 +0,0 @@ -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 deleted file mode 100644 index e44a3188..00000000 --- a/src/OpenID/SecLibJWTKey.php +++ /dev/null @@ -1,66 +0,0 @@ -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 deleted file mode 100644 index d8689e4e..00000000 --- a/src/OpenID/SecLibJWTKeyAlgo.php +++ /dev/null @@ -1,114 +0,0 @@ -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 deleted file mode 100644 index 04f2eb44..00000000 --- a/src/OpenID/SingleJWTKeySet.php +++ /dev/null @@ -1,33 +0,0 @@ - [$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; - } -}