JWT out of the OpenID namespace and merged the remainder with the OAuth namespace.
This commit is contained in:
parent
b2f8347526
commit
446c675613
20 changed files with 622 additions and 679 deletions
|
@ -1,23 +1,21 @@
|
|||
<?php
|
||||
namespace Misuzu\OpenID;
|
||||
namespace Misuzu\JWT;
|
||||
|
||||
use RuntimeException;
|
||||
use Index\UriBase64;
|
||||
use phpseclib3\Crypt\PublicKeyLoader;
|
||||
use phpseclib3\Crypt\Common\{AsymmetricKey,PrivateKey,PublicKey};
|
||||
use phpseclib3\Math\BigInteger;
|
||||
|
||||
class ArrayJWTKeySet implements JWTKeySet {
|
||||
/** @param array<string, JWTKey> $keys */
|
||||
class ArrayJWKSet implements JWKSet {
|
||||
/** @param array<string, JWK> $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);
|
||||
}
|
||||
}
|
55
src/JWT/HMACJWK.php
Normal file
55
src/JWT/HMACJWK.php
Normal file
|
@ -0,0 +1,55 @@
|
|||
<?php
|
||||
namespace Misuzu\JWT;
|
||||
|
||||
use InvalidArgumentException;
|
||||
|
||||
class HMACJWK implements JWK {
|
||||
/** @var array<string, string> */
|
||||
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();
|
13
src/JWT/JWK.php
Normal file
13
src/JWT/JWK.php
Normal file
|
@ -0,0 +1,13 @@
|
|||
<?php
|
||||
namespace Misuzu\JWT;
|
||||
|
||||
use RuntimeException;
|
||||
|
||||
interface JWK {
|
||||
public ?string $id { get; }
|
||||
public ?string $algo { get; }
|
||||
|
||||
/** @throws RuntimeException if this instance can only be used for verification */
|
||||
public function sign(string $data, ?string $algo = null): string;
|
||||
public function verify(string $data, string $signature, ?string $algo = null): bool;
|
||||
}
|
13
src/JWT/JWKSet.php
Normal file
13
src/JWT/JWKSet.php
Normal file
|
@ -0,0 +1,13 @@
|
|||
<?php
|
||||
namespace Misuzu\JWT;
|
||||
|
||||
use Countable;
|
||||
use RuntimeException;
|
||||
|
||||
interface JWKSet {
|
||||
/** @var array<string, JWK> */
|
||||
public array $keys { get; }
|
||||
|
||||
/** @throws RuntimeException if no match could be found */
|
||||
public function getKey(?string $keyId = null, ?string $algo = null): JWK;
|
||||
}
|
|
@ -1,42 +1,37 @@
|
|||
<?php
|
||||
namespace Misuzu\OpenID;
|
||||
namespace Misuzu\JWT;
|
||||
|
||||
use InvalidArgumentException;
|
||||
use RuntimeException;
|
||||
use UnexpectedValueException;
|
||||
use Index\{UriBase64,XDateTime};
|
||||
use phpseclib3\Crypt\Common\{AsymmetricKey,PrivateKey,PublicKey};
|
||||
|
||||
// based on https://github.com/firebase/php-jwt/blob/8f718f4dfc9c5d5f0c994cdfd103921b43592712/src/JWT.php
|
||||
class JWT {
|
||||
public static function encode(
|
||||
array|object $payload,
|
||||
JWTKey|(AsymmetricKey&PrivateKey)|string $key,
|
||||
?string $keyId = null,
|
||||
JWK $key,
|
||||
?string $alg = null,
|
||||
array|object|null $header = null
|
||||
): string {
|
||||
if(is_string($key))
|
||||
$key = new HMACJWTKey($alg ?? HMACJWTKey::DEFAULT_ALGO, $key);
|
||||
elseif($key instanceof AsymmetricKey)
|
||||
$key = new SecLibJWTKey($alg ?? SecLibJWTKey::inferAlgorithm($key), $key);
|
||||
|
||||
$head = ['typ' => 'JWT'];
|
||||
if($header !== null)
|
||||
$head = array_merge($head, (array)$header);
|
||||
$head['alg'] = $key->algo;
|
||||
if($keyId !== null)
|
||||
$head['kid'] = $keyId;
|
||||
$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']))) {
|
51
src/JWT/SecLibJWK.php
Normal file
51
src/JWT/SecLibJWK.php
Normal file
|
@ -0,0 +1,51 @@
|
|||
<?php
|
||||
namespace Misuzu\JWT;
|
||||
|
||||
use RuntimeException;
|
||||
use phpseclib3\Crypt\Common\{AsymmetricKey,PrivateKey,PublicKey};
|
||||
|
||||
class SecLibJWK implements JWK {
|
||||
public private(set) ?SecLibJWKAlgo $algoInfo;
|
||||
|
||||
public ?string $algo {
|
||||
get => $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);
|
||||
}
|
||||
}
|
161
src/JWT/SecLibJWKAlgo.php
Normal file
161
src/JWT/SecLibJWKAlgo.php
Normal file
|
@ -0,0 +1,161 @@
|
|||
<?php
|
||||
namespace Misuzu\JWT;
|
||||
|
||||
use InvalidArgumentException;
|
||||
use phpseclib3\Crypt\{EC,RSA};
|
||||
use phpseclib3\Crypt\Common\AsymmetricKey;
|
||||
|
||||
class SecLibJWKAlgo {
|
||||
/** @var array<string, callable(AsymmetricKey): AsymmetricKey> */
|
||||
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();
|
|
@ -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));
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
96
src/OAuth2/OAuth2Keys.php
Normal file
96
src/OAuth2/OAuth2Keys.php
Normal file
|
@ -0,0 +1,96 @@
|
|||
<?php
|
||||
namespace Misuzu\OAuth2;
|
||||
|
||||
use stdClass;
|
||||
use InvalidArgumentException;
|
||||
use RuntimeException;
|
||||
use Misuzu\Misuzu;
|
||||
use Misuzu\JWT\{ArrayJWKSet,JWKSet,SecLibJWK};
|
||||
use phpseclib3\Crypt\PublicKeyLoader;
|
||||
use phpseclib3\Crypt\Common\{AsymmetricKey,PrivateKey,PublicKey};
|
||||
|
||||
class OAuth2Keys {
|
||||
private const string FORMAT = '%s/keys/%s-%s.pem';
|
||||
|
||||
/** @var array<string, object{id: string, alg: string}> */
|
||||
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<string, mixed[]> */
|
||||
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;
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -1,33 +0,0 @@
|
|||
<?php
|
||||
namespace Misuzu\OpenID;
|
||||
|
||||
use InvalidArgumentException;
|
||||
|
||||
class HMACJWTKey implements JWTKey {
|
||||
public const string DEFAULT_ALGO = 'HS256';
|
||||
public const array ALGOS = [
|
||||
'HS256' => 'sha256',
|
||||
'HS384' => 'sha384',
|
||||
'HS512' => 'sha512',
|
||||
];
|
||||
|
||||
private string $realAlgo;
|
||||
|
||||
public function __construct(
|
||||
public private(set) string $algo,
|
||||
#[\SensitiveParameter] private string $key
|
||||
) {
|
||||
if(!array_key_exists($algo, self::ALGOS))
|
||||
throw new InvalidArgumentException('unsupported algorithm');
|
||||
|
||||
$this->realAlgo = self::ALGOS[$algo];
|
||||
}
|
||||
|
||||
public function sign(string $data): string {
|
||||
return hash_hmac($this->realAlgo, $data, $this->key, true);
|
||||
}
|
||||
|
||||
public function verify(string $data, string $signature): bool {
|
||||
return hash_equals($this->sign($data), $signature);
|
||||
}
|
||||
}
|
|
@ -1,12 +0,0 @@
|
|||
<?php
|
||||
namespace Misuzu\OpenID;
|
||||
|
||||
use RuntimeException;
|
||||
|
||||
interface JWTKey {
|
||||
public string $algo { get; }
|
||||
|
||||
/** @throws RuntimeException if this instance can only be used for verification */
|
||||
public function sign(string $data): string;
|
||||
public function verify(string $data, string $signature): bool;
|
||||
}
|
|
@ -1,13 +0,0 @@
|
|||
<?php
|
||||
namespace Misuzu\OpenID;
|
||||
|
||||
use Countable;
|
||||
use RuntimeException;
|
||||
|
||||
interface JWTKeySet extends Countable {
|
||||
/** @var array<string, JWTKey> */
|
||||
public array $keys { get; }
|
||||
|
||||
/** @throws RuntimeException if no match could be found */
|
||||
public function getKey(?string $keyId = null, ?string $alg = null): JWTKey;
|
||||
}
|
|
@ -1,178 +0,0 @@
|
|||
<?php
|
||||
namespace Misuzu\OpenID;
|
||||
|
||||
use RuntimeException;
|
||||
use Index\Config\Config;
|
||||
use Misuzu\{Misuzu,SiteInfo};
|
||||
use Misuzu\Apps\AppInfo;
|
||||
use Misuzu\OAuth2\{OAuth2AccessInfo,OAuth2AuthorisationInfo};
|
||||
use Misuzu\Users\UserInfo;
|
||||
use phpseclib3\Crypt\PublicKeyLoader;
|
||||
use phpseclib3\Crypt\Common\{AsymmetricKey,PrivateKey,PublicKey};
|
||||
|
||||
class OpenIDContext {
|
||||
private const string KEY_FORMAT = '%s/keys/%s-%s.pem';
|
||||
|
||||
public function __construct(
|
||||
private SiteInfo $siteInfo,
|
||||
private Config $config,
|
||||
) {}
|
||||
|
||||
/** @var string[] */
|
||||
public array $keyIds {
|
||||
get => $this->config->getArray('keys');
|
||||
}
|
||||
|
||||
public string $websiteProfileField {
|
||||
get => $this->config->getString('website_profile_field');
|
||||
}
|
||||
|
||||
public static function getDataForIdToken(
|
||||
SiteInfo $siteInfo,
|
||||
AppInfo $appInfo,
|
||||
OAuth2AccessInfo|OAuth2AuthorisationInfo $accessOrAuthzInfo,
|
||||
?string $nonce = null,
|
||||
?int $issuedAt = null
|
||||
): array {
|
||||
$token = [
|
||||
'iss' => $siteInfo->url,
|
||||
'sub' => $accessOrAuthzInfo->userId,
|
||||
'aud' => $appInfo->clientId,
|
||||
'exp' => $accessOrAuthzInfo->expiresTime,
|
||||
'iat' => $issuedAt ?? time(),
|
||||
'auth_time' => $accessOrAuthzInfo->createdTime,
|
||||
];
|
||||
if($nonce !== null) // keep track of this somehow, must be taken from /oauth2/authorize args
|
||||
$token['nonce'] = $nonce;
|
||||
|
||||
return $token;
|
||||
}
|
||||
|
||||
public function createIdToken(
|
||||
AppInfo $appInfo,
|
||||
OAuth2AccessInfo|OAuth2AuthorisationInfo $accessOrAuthzInfo,
|
||||
?string $nonce = null,
|
||||
?int $issuedAt = null,
|
||||
?string $keyId = null
|
||||
): string {
|
||||
return JWT::encode(
|
||||
self::getDataForIdToken(
|
||||
$this->siteInfo,
|
||||
$appInfo,
|
||||
$accessOrAuthzInfo,
|
||||
$nonce,
|
||||
$issuedAt
|
||||
),
|
||||
$this->getPrivateJWTKeySet()->getKey($keyId),
|
||||
$keyId
|
||||
);
|
||||
}
|
||||
|
||||
public function getPublicKey(string $keyId): AsymmetricKey&PublicKey {
|
||||
$path = realpath(sprintf(self::KEY_FORMAT, Misuzu::PATH_CONFIG, $keyId, 'pub'));
|
||||
if($path === false)
|
||||
throw new RuntimeException(sprintf('public key "%s" could not be found', $keyId));
|
||||
|
||||
$body = file_get_contents($path);
|
||||
if($body === false)
|
||||
throw new RuntimeException(sprintf('public key "%s" could not be read', $keyId));
|
||||
|
||||
return PublicKeyLoader::loadPublicKey($body);
|
||||
}
|
||||
|
||||
/** @return array<string, AsymmetricKey&PublicKey> */
|
||||
public function getPublicKeys(): array {
|
||||
$keys = [];
|
||||
foreach($this->keyIds as $keyId)
|
||||
$keys[$keyId] = $this->getPublicKey($keyId);
|
||||
|
||||
return $keys;
|
||||
}
|
||||
|
||||
public function getPublicJWTKeySet(): JWTKeySet {
|
||||
$keys = [];
|
||||
foreach($this->keyIds as $keyId) {
|
||||
$key = $this->getPublicKey($keyId);
|
||||
$keys[$keyId] = new SecLibJWTKey(SecLibJWTKey::inferAlgorithm($key), $key);
|
||||
}
|
||||
|
||||
return new ArrayJWTKeySet($keys);
|
||||
}
|
||||
|
||||
/** @return array<string, mixed[]> */
|
||||
public function getPublicJWTsForJson(): array {
|
||||
$keys = [];
|
||||
|
||||
foreach($this->keyIds as $keyId) {
|
||||
$publicKey = $this->getPublicKey($keyId);
|
||||
$options = [
|
||||
'kid' => $keyId,
|
||||
'use' => 'sig',
|
||||
'alg' => SecLibJWTKey::inferAlgorithm($publicKey),
|
||||
];
|
||||
|
||||
$keys = array_merge_recursive(
|
||||
$keys,
|
||||
json_decode($publicKey->toString('JWK', $options), true)
|
||||
);
|
||||
}
|
||||
|
||||
return $keys;
|
||||
}
|
||||
|
||||
public function getFirstPrivateKey(): AsymmetricKey&PrivateKey {
|
||||
return $this->getPrivateKey($this->keyIds[0]);
|
||||
}
|
||||
|
||||
public function getPrivateKey(string $keyId): AsymmetricKey&PrivateKey {
|
||||
$path = realpath(sprintf(self::KEY_FORMAT, Misuzu::PATH_CONFIG, $keyId, 'priv'));
|
||||
if($path === false)
|
||||
throw new RuntimeException(sprintf('private key "%s" could not be found', $keyId));
|
||||
|
||||
$body = file_get_contents($path);
|
||||
if($body === false)
|
||||
throw new RuntimeException(sprintf('private key "%s" could not be read', $keyId));
|
||||
|
||||
return PublicKeyLoader::loadPrivateKey($body);
|
||||
}
|
||||
|
||||
/** @return array<string, AsymmetricKey&PrivateKey> */
|
||||
public function getPrivateKeys(): array {
|
||||
$keys = [];
|
||||
foreach($this->keyIds as $keyId)
|
||||
$keys[$keyId] = $this->getPrivateKey($keyId);
|
||||
|
||||
return $keys;
|
||||
}
|
||||
|
||||
public function getPrivateJWTKeySet(): JWTKeySet {
|
||||
$keys = [];
|
||||
foreach($this->keyIds as $keyId) {
|
||||
$key = $this->getPrivateKey($keyId);
|
||||
$keys[$keyId] = new SecLibJWTKey(SecLibJWTKey::inferAlgorithm($key), $key);
|
||||
}
|
||||
|
||||
return new ArrayJWTKeySet($keys);
|
||||
}
|
||||
|
||||
/** @return array<string, mixed[]> */
|
||||
public function getPrivateJWTsForJson(): array {
|
||||
$keys = [];
|
||||
|
||||
foreach($this->keyIds as $keyId) {
|
||||
$privateKey = $this->getPrivateKey($keyId);
|
||||
$options = [
|
||||
'kid' => $keyId,
|
||||
'use' => 'sig',
|
||||
'alg' => SecLibJWTKey::inferAlgorithm($privateKey),
|
||||
];
|
||||
|
||||
$keys = array_merge_recursive(
|
||||
$keys,
|
||||
json_decode($privateKey->toString('JWK', $options), true)
|
||||
);
|
||||
}
|
||||
|
||||
return $keys;
|
||||
}
|
||||
}
|
|
@ -1,178 +0,0 @@
|
|||
<?php
|
||||
namespace Misuzu\OpenID;
|
||||
|
||||
use RuntimeException;
|
||||
use Index\{UriBase64,XArray,XString};
|
||||
use Index\Colour\{Colour,ColourRgb};
|
||||
use Index\Http\{HttpResponseBuilder,HttpRequest};
|
||||
use Index\Http\Routing\{HttpGet,HttpOptions,RouteHandler,RouteHandlerCommon};
|
||||
use Index\Urls\{UrlFormat,UrlRegistry,UrlSource,UrlSourceCommon};
|
||||
use Misuzu\{Misuzu,SiteInfo};
|
||||
use Misuzu\Auth\AuthInfo;
|
||||
use Misuzu\OAuth2\{OAuth2AccessInfoGetField,OAuth2Context};
|
||||
use Misuzu\Profile\ProfileContext;
|
||||
use Misuzu\Users\UsersContext;
|
||||
|
||||
class OpenIDRoutes implements RouteHandler, UrlSource {
|
||||
use RouteHandlerCommon, UrlSourceCommon;
|
||||
|
||||
public function __construct(
|
||||
private OpenIDContext $openIdCtx,
|
||||
private OAuth2Context $oauth2Ctx,
|
||||
private UsersContext $usersCtx,
|
||||
private ProfileContext $profileCtx,
|
||||
private SiteInfo $siteInfo,
|
||||
private AuthInfo $authInfo,
|
||||
private UrlRegistry $urls,
|
||||
) {}
|
||||
|
||||
#[HttpOptions('/.well-known/openid-configuration')]
|
||||
#[HttpGet('/.well-known/openid-configuration')]
|
||||
public function getWellKnown(HttpResponseBuilder $response, HttpRequest $request): array|int {
|
||||
$response->setHeader('Access-Control-Allow-Origin', '*');
|
||||
if($request->method === 'OPTIONS')
|
||||
return 204;
|
||||
|
||||
$signingAlgs = array_values(array_unique(XArray::select($this->openIdCtx->getPublicJWTKeySet()->keys, fn($key) => $key->algo)));
|
||||
sort($signingAlgs);
|
||||
|
||||
return [
|
||||
'issuer' => $this->siteInfo->url,
|
||||
'authorization_endpoint' => sprintf('%s%s', $this->siteInfo->url, $this->urls->format('oauth2-authorise')),
|
||||
'token_endpoint' => sprintf('%s%s', $this->siteInfo->url, $this->urls->format('oauth2-token')),
|
||||
'userinfo_endpoint' => sprintf('%s%s', $this->siteInfo->url, $this->urls->format('openid-userinfo')),
|
||||
'jwks_uri' => sprintf('%s%s', $this->siteInfo->url, $this->urls->format('openid-jwks')),
|
||||
'response_types_supported' => ['code', 'code id_token'],
|
||||
'response_modes_supported' => ['query'],
|
||||
'grant_types_supported' => ['authorization_code'],
|
||||
'subject_types_supported' => ['public'],
|
||||
'id_token_signing_alg_values_supported' => $signingAlgs,
|
||||
'token_endpoint_auth_methods_supported' => ['client_secret_basic', 'client_secret_post'],
|
||||
];
|
||||
}
|
||||
|
||||
#[HttpOptions('/openid/userinfo')]
|
||||
#[HttpGet('/openid/userinfo')]
|
||||
#[UrlFormat('openid-userinfo', '/openid/userinfo')]
|
||||
public function getUserInfo(HttpResponseBuilder $response, HttpRequest $request): int|array {
|
||||
$response->setHeader('Access-Control-Allow-Origin', '*');
|
||||
$response->setHeader('Access-Control-Allow-Headers', 'Authorization');
|
||||
if($request->method === 'OPTIONS')
|
||||
return 204;
|
||||
|
||||
$header = explode(' ', (string)$request->getHeaderLine('Authorization'), 2);
|
||||
if(strcasecmp($header[0], 'bearer') !== 0 || empty($header[1])) {
|
||||
$response->statusCode = 401;
|
||||
$response->setHeader('WWW-Authenticate', 'Bearer error="invalid_token", error_description="Bearer authentication must be used."');
|
||||
return [
|
||||
'error' => 'invalid_token',
|
||||
'error_description' => 'Bearer authentication must be used.',
|
||||
];
|
||||
}
|
||||
|
||||
try {
|
||||
$tokenInfo = $this->oauth2Ctx->tokens->getAccessInfo(trim($header[1]), OAuth2AccessInfoGetField::Token);
|
||||
} catch(RuntimeException $ex) {
|
||||
$tokenInfo = null;
|
||||
}
|
||||
if($tokenInfo === null || $tokenInfo->userId === null || $tokenInfo->expired) {
|
||||
$response->statusCode = 401;
|
||||
$response->setHeader('WWW-Authenticate', 'Bearer error="invalid_token", error_description="Access token has expired."');
|
||||
return [
|
||||
'error' => 'invalid_token',
|
||||
'error_description' => 'Access token has expired.',
|
||||
];
|
||||
}
|
||||
|
||||
$scopes = $tokenInfo->scopes;
|
||||
if(!in_array('openid', $scopes)) {
|
||||
$response->statusCode = 403;
|
||||
$response->setHeader('WWW-Authenticate', 'Bearer error="insufficient_scope", error_description="openid scope is required for this endpoint."');
|
||||
return [
|
||||
'error' => 'insufficient_scope',
|
||||
'error_description' => 'openid scope is required for this endpoint.',
|
||||
];
|
||||
}
|
||||
|
||||
try {
|
||||
$userInfo = $this->usersCtx->getUserInfo($tokenInfo->userId);
|
||||
} catch(RuntimeException $ex) {
|
||||
$userInfo = null;
|
||||
}
|
||||
if($userInfo === null || $userInfo->deleted) {
|
||||
$response->statusCode = 401;
|
||||
$response->setHeader('WWW-Authenticate', 'Bearer error="invalid_token", error_description="Access token has expired."');
|
||||
return [
|
||||
'error' => 'invalid_token',
|
||||
'error_description' => 'Access token has expired.',
|
||||
];
|
||||
}
|
||||
|
||||
$result = ['sub' => $userInfo->id];
|
||||
|
||||
if(in_array('profile', $scopes)) {
|
||||
$result['name'] = $userInfo->name;
|
||||
$result['nickname'] = $userInfo->name;
|
||||
$result['preferred_username'] = $userInfo->name;
|
||||
$result['profile'] = sprintf('%s%s', $this->siteInfo->url, $this->urls->format('user-profile', ['user' => $userInfo->id]));
|
||||
$result['picture'] = sprintf('%s%s', $this->siteInfo->url, $this->urls->format('user-avatar', ['user' => $userInfo->id, 'res' => 200]));
|
||||
$result['zoneinfo'] = 'UTC'; // todo: let users specify this
|
||||
|
||||
$birthdateInfo = $this->usersCtx->birthdates->getUserBirthdate($userInfo);
|
||||
if($birthdateInfo !== null)
|
||||
$result['birthdate'] = $birthdateInfo->birthdate;
|
||||
|
||||
$websiteFieldId = $this->openIdCtx->websiteProfileField;
|
||||
if(!empty($websiteFieldId))
|
||||
try {
|
||||
$result['website'] = $this->profileCtx->fields->getFieldValue($websiteFieldId, $userInfo)->value;
|
||||
} catch(RuntimeException $ex) {}
|
||||
|
||||
$result['http://railgun.sh/country_code'] = $userInfo->countryCode;
|
||||
|
||||
$colour = $this->usersCtx->getUserColour($userInfo);
|
||||
if($colour->inherits) {
|
||||
$result['http://railgun.sh/colour_raw'] = null;
|
||||
$result['http://railgun.sh/colour_css'] = (string)$colour;
|
||||
} else {
|
||||
$result['http://railgun.sh/colour_raw'] = Colour::toRawRgb($colour);
|
||||
$result['http://railgun.sh/colour_css'] = (string)ColourRgb::convert($colour);
|
||||
}
|
||||
|
||||
if($this->usersCtx->hasActiveBan($userInfo)) {
|
||||
$result['http://railgun.sh/rank'] = 0;
|
||||
$result['http://railgun.sh/banned'] = true;
|
||||
$result['http://railgun.sh/roles'] = ['x-banned'];
|
||||
} else {
|
||||
$roles = XArray::select(
|
||||
$this->usersCtx->roles->getRoles(userInfo: $userInfo, hasString: true, orderByRank: true),
|
||||
fn($roleInfo) => $roleInfo->string,
|
||||
);
|
||||
|
||||
$result['http://railgun.sh/rank'] = $this->usersCtx->getUserRank($userInfo);
|
||||
if($userInfo->super)
|
||||
$result['http://railgun.sh/is_super'] = true;
|
||||
if(!empty($roles))
|
||||
$result['http://railgun.sh/roles'] = $roles;
|
||||
}
|
||||
}
|
||||
|
||||
if(in_array('email', $scopes)) {
|
||||
$result['email'] = $userInfo->emailAddress;
|
||||
$result['email_verified'] = true;
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
#[HttpOptions('/openid/jwks.json')]
|
||||
#[HttpGet('/openid/jwks.json')]
|
||||
#[UrlFormat('openid-jwks', '/openid/jwks.json')]
|
||||
public function getJwks(HttpResponseBuilder $response, HttpRequest $request): int|array {
|
||||
$response->setHeader('Access-Control-Allow-Origin', '*');
|
||||
if($request->method === 'OPTIONS')
|
||||
return 204;
|
||||
|
||||
return $this->openIdCtx->getPublicJWTsForJson();
|
||||
}
|
||||
}
|
|
@ -1,66 +0,0 @@
|
|||
<?php
|
||||
namespace Misuzu\OpenID;
|
||||
|
||||
use RuntimeException;
|
||||
use phpseclib3\Crypt\Common\{AsymmetricKey,PrivateKey,PublicKey};
|
||||
use phpseclib3\Crypt\EC\PrivateKey as ECPrivateKey;
|
||||
use phpseclib3\Crypt\EC\PublicKey as ECPublicKey;
|
||||
use phpseclib3\Crypt\RSA\PrivateKey as RSAPrivateKey;
|
||||
use phpseclib3\Crypt\RSA\PublicKey as RSAPublicKey;
|
||||
|
||||
class SecLibJWTKey implements JWTKey {
|
||||
private ?PrivateKey $privKey;
|
||||
private PublicKey $pubKey;
|
||||
private SecLibJWTKeyAlgo $algoInfo;
|
||||
|
||||
public string $algo;
|
||||
|
||||
public function __construct(
|
||||
SecLibJWTKeyAlgo|string $algo,
|
||||
#[\SensitiveParameter] (AsymmetricKey&PrivateKey)|(AsymmetricKey&PublicKey) $key
|
||||
) {
|
||||
if(is_string($algo))
|
||||
$algo = SecLibJWTKeyAlgo::default($algo);
|
||||
|
||||
$key = $algo->transform($key);
|
||||
|
||||
$this->algo = $algo->algo;
|
||||
if($key instanceof PrivateKey) {
|
||||
$this->privKey = $key;
|
||||
$this->pubKey = $key->getPublicKey();
|
||||
} else {
|
||||
$this->privKey = null;
|
||||
$this->pubKey = $key;
|
||||
}
|
||||
}
|
||||
|
||||
public function sign(string $data): string {
|
||||
if($this->privKey === null)
|
||||
throw new RuntimeException('this instance does not have a private key, it can only be used to verify');
|
||||
|
||||
return $this->privKey->sign($data);
|
||||
}
|
||||
|
||||
public function verify(string $data, string $signature): bool {
|
||||
return $this->pubKey->verify($data, $signature);
|
||||
}
|
||||
|
||||
// you probably should not rely on this
|
||||
public static function inferAlgorithm(PrivateKey|PublicKey $key): string {
|
||||
if($key instanceof RSAPrivateKey || $key instanceof RSAPublicKey)
|
||||
return 'RS256';
|
||||
|
||||
if($key instanceof ECPrivateKey || $key instanceof ECPublicKey)
|
||||
return match($key->getCurve()) {
|
||||
'secp256r1' => 'ES256',
|
||||
'secp256k1' => 'ES256K',
|
||||
'secp384r1' => 'ES384',
|
||||
'secp521r1' => 'ES512',
|
||||
'Ed25519' => 'EdDSA',
|
||||
'Ed448' => 'EdDSA',
|
||||
default => throw new RuntimeException('unknown EC curve'),
|
||||
};
|
||||
|
||||
throw new RuntimeException('unsupported asymmetric key format');
|
||||
}
|
||||
}
|
|
@ -1,114 +0,0 @@
|
|||
<?php
|
||||
namespace Misuzu\OpenID;
|
||||
|
||||
use InvalidArgumentException;
|
||||
use phpseclib3\Crypt\{EC,RSA};
|
||||
use phpseclib3\Crypt\Common\AsymmetricKey;
|
||||
|
||||
class SecLibJWTKeyAlgo {
|
||||
public function __construct(
|
||||
public private(set) string $algo,
|
||||
private $transformFunc
|
||||
) {
|
||||
if(!is_callable($transformFunc))
|
||||
throw new InvalidArgumentException('$transformFunc must be callable');
|
||||
}
|
||||
|
||||
public function transform(AsymmetricKey $key): AsymmetricKey {
|
||||
return ($this->transformFunc)($key);
|
||||
}
|
||||
|
||||
public static function ensureRSAKey(AsymmetricKey $key): void {
|
||||
if(!($key instanceof RSA))
|
||||
throw new InvalidArgumentException('$key must be an RSA key');
|
||||
}
|
||||
|
||||
public static function applyPKCS1Padding(AsymmetricKey $key): AsymmetricKey {
|
||||
return $key->withPadding(RSA::SIGNATURE_PKCS1);
|
||||
}
|
||||
|
||||
public static function applyPSSPadding(AsymmetricKey $key): AsymmetricKey {
|
||||
return $key->withPadding(RSA::SIGNATURE_PSS);
|
||||
}
|
||||
|
||||
public static function ensureECKey(AsymmetricKey $key): void {
|
||||
if(!($key instanceof EC))
|
||||
throw new InvalidArgumentException('$key must be an EC key');
|
||||
}
|
||||
|
||||
public static function applyIEEESignatureFormat(AsymmetricKey $key): AsymmetricKey {
|
||||
return $key->withSignatureFormat('IEEE');
|
||||
}
|
||||
|
||||
public static function default(string $algo): SecLibJWTKeyAlgo {
|
||||
return new SecLibJWTKeyAlgo($algo, match($algo) {
|
||||
// RSxxx
|
||||
'RS256' => function(AsymmetricKey $key): AsymmetricKey {
|
||||
self::ensureRSAKey($key);
|
||||
return self::applyPKCS1Padding($key)->withHash('sha256');
|
||||
},
|
||||
'RS384' => function(AsymmetricKey $key): AsymmetricKey {
|
||||
self::ensureRSAKey($key);
|
||||
return self::applyPKCS1Padding($key)->withHash('sha384');
|
||||
},
|
||||
'RS512' => function(AsymmetricKey $key): AsymmetricKey {
|
||||
self::ensureRSAKey($key);
|
||||
return self::applyPKCS1Padding($key)->withHash('sha512');
|
||||
},
|
||||
|
||||
// PSxxx
|
||||
'PS256' => function(AsymmetricKey $key): AsymmetricKey {
|
||||
self::ensureRSAKey($key);
|
||||
return self::applyPSSPadding($key)->withHash('sha256');
|
||||
},
|
||||
'PS384' => function(AsymmetricKey $key): AsymmetricKey {
|
||||
self::ensureRSAKey($key);
|
||||
return self::applyPSSPadding($key)->withHash('sha384');
|
||||
},
|
||||
'PS512' => function(AsymmetricKey $key): AsymmetricKey {
|
||||
self::ensureRSAKey($key);
|
||||
return self::applyPSSPadding($key)->withHash('sha512');
|
||||
},
|
||||
|
||||
// ESxxxy
|
||||
'ES256' => function(AsymmetricKey $key): AsymmetricKey {
|
||||
self::ensureECKey($key);
|
||||
if($key->getCurve() !== 'secp256r1')
|
||||
throw new InvalidArgumentException('curve must be secp256r1');
|
||||
|
||||
return self::applyIEEESignatureFormat($key)->withHash('sha256');
|
||||
},
|
||||
'ES256K' => function(AsymmetricKey $key): AsymmetricKey {
|
||||
self::ensureECKey($key);
|
||||
if($key->getCurve() !== 'secp256k1')
|
||||
throw new InvalidArgumentException('curve must be secp256k1');
|
||||
|
||||
return self::applyIEEESignatureFormat($key)->withHash('sha256');
|
||||
},
|
||||
'ES384' => function(AsymmetricKey $key): AsymmetricKey {
|
||||
self::ensureECKey($key);
|
||||
if($key->getCurve() !== 'secp384r1')
|
||||
throw new InvalidArgumentException('curve must be secp384r1');
|
||||
|
||||
return self::applyIEEESignatureFormat($key)->withHash('sha384');
|
||||
},
|
||||
'ES512' => function(AsymmetricKey $key): AsymmetricKey {
|
||||
self::ensureECKey($key);
|
||||
if($key->getCurve() !== 'secp521r1')
|
||||
throw new InvalidArgumentException('curve must be secp521r1');
|
||||
|
||||
return self::applyIEEESignatureFormat($key)->withHash('sha512');
|
||||
},
|
||||
|
||||
'EdDSA' => function(AsymmetricKey $key): AsymmetricKey {
|
||||
self::ensureECKey($key);
|
||||
if($key->getCurve() !== 'Ed25519' && $key->getCurve() !== 'Ed448')
|
||||
throw new InvalidArgumentException('curve must be Ed25519 or Ed448');
|
||||
|
||||
return $key;
|
||||
},
|
||||
|
||||
default => throw new InvalidArgumentException(sprintf('unsupported algorithm: "%s"', $algo)),
|
||||
});
|
||||
}
|
||||
}
|
|
@ -1,33 +0,0 @@
|
|||
<?php
|
||||
namespace Misuzu\OpenID;
|
||||
|
||||
use RuntimeException;
|
||||
use phpseclib3\Crypt\Common\{AsymmetricKey,PrivateKey,PublicKey};
|
||||
|
||||
class SingleJWTKeySet implements JWTKeySet {
|
||||
private JWTKey $key;
|
||||
|
||||
public array $keys {
|
||||
get => [$key];
|
||||
}
|
||||
|
||||
public function __construct(JWTKey|(AsymmetricKey&PrivateKey)|(AsymmetricKey&PublicKey)|string $key) {
|
||||
if(is_string($key))
|
||||
$this->key = new HMACJWTKeyKey(HMACJWTKeyKey::DEFAULT_ALGO, $key);
|
||||
elseif($key instanceof AsymmetricKey)
|
||||
$this->key = new SecLibJWTKey(SecLibJWTKey::inferAlgorithm($key), $key);
|
||||
else
|
||||
$this->key = $key;
|
||||
}
|
||||
|
||||
public function getKey(?string $keyId = null, ?string $alg = null): JWTKey {
|
||||
if($alg !== null && $this->key->algo !== $alg)
|
||||
throw new RuntimeException('key does not match requested algorithm');
|
||||
|
||||
return $this->key;
|
||||
}
|
||||
|
||||
public function count(): int {
|
||||
return 1;
|
||||
}
|
||||
}
|
Loading…
Add table
Reference in a new issue