JWT out of the OpenID namespace and merged the remainder with the OAuth namespace.

This commit is contained in:
flash 2025-02-26 21:01:32 +00:00
parent b2f8347526
commit 446c675613
Signed by: flash
GPG key ID: 2C9C2C574D47FE3E
20 changed files with 622 additions and 679 deletions

View file

@ -1,23 +1,21 @@
<?php <?php
namespace Misuzu\OpenID; namespace Misuzu\JWT;
use RuntimeException; use RuntimeException;
use Index\UriBase64;
use phpseclib3\Crypt\PublicKeyLoader; use phpseclib3\Crypt\PublicKeyLoader;
use phpseclib3\Crypt\Common\{AsymmetricKey,PrivateKey,PublicKey}; use phpseclib3\Crypt\Common\{AsymmetricKey,PrivateKey,PublicKey};
use phpseclib3\Math\BigInteger;
class ArrayJWTKeySet implements JWTKeySet { class ArrayJWKSet implements JWKSet {
/** @param array<string, JWTKey> $keys */ /** @param array<string, JWK> $keys */
public function __construct(public private(set) 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($keyId === null) {
if($alg === null) if($algo === null)
return $this->keys[array_key_first($this->keys)]; return $this->keys[array_key_first($this->keys)];
foreach($this->keys as $key) foreach($this->keys as $key)
if(hash_equals($key->algo, $alg)) if($algo !== null && $key->algo !== null && hash_equals($key->algo, $algo))
return $key; return $key;
throw new RuntimeException('could not find a key that matched the requested algorithm'); 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'); throw new RuntimeException('could not find a key with that id');
$key = $this->keys[$keyId]; $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'); throw new RuntimeException('requested algorithm does not match');
return $key; return $key;
} }
public function count(): int { public static function decodeJson(string|array|object $json): ArrayJWKSet {
return count($this->keys);
}
public static function decodeJson(string|array|object $json): ArrayJWTKeySet {
if(is_object($json)) if(is_object($json))
$json = (array)$json; $json = (array)$json;
elseif(is_string($json)) elseif(is_string($json))
@ -47,7 +41,7 @@ class ArrayJWTKeySet implements JWTKeySet {
$json = (object)$json; $json = (object)$json;
if(!property_exists($json, 'keys') || !is_array($json->keys) || !array_is_list($json->keys)) if(!property_exists($json, 'keys') || !is_array($json->keys) || !array_is_list($json->keys))
return new ArrayJWTKeySet([]); return new ArrayJWKSet([]);
$keys = []; $keys = [];
@ -55,10 +49,7 @@ class ArrayJWTKeySet implements JWTKeySet {
$key = PublicKeyLoader::load(json_encode($keyInfo)); $key = PublicKeyLoader::load(json_encode($keyInfo));
if($key instanceof AsymmetricKey && ($key instanceof PrivateKey || $key instanceof PublicKey)) if($key instanceof AsymmetricKey && ($key instanceof PrivateKey || $key instanceof PublicKey))
$key = new SecLibJWTKey( $key = new SecLibJWK($key, property_exists($keyInfo, 'alg') && is_string($keyInfo->alg) ? $keyInfo->alg : null);
property_exists($keyInfo, 'alg') && is_string($keyInfo->alg) ? $keyInfo->alg : SetLibJWTKey::inferAlgorithm($key),
$key
);
if(property_exists($keyInfo, 'kid') && is_string($keyInfo->kid)) if(property_exists($keyInfo, 'kid') && is_string($keyInfo->kid))
$keys[$keyInfo->kid] = $key; $keys[$keyInfo->kid] = $key;
@ -66,6 +57,6 @@ class ArrayJWTKeySet implements JWTKeySet {
$keys[] = $key; $keys[] = $key;
} }
return new ArrayJWTKeySet($keys); return new ArrayJWKSet($keys);
} }
} }

55
src/JWT/HMACJWK.php Normal file
View 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
View 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
View 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;
}

View file

@ -1,42 +1,37 @@
<?php <?php
namespace Misuzu\OpenID; namespace Misuzu\JWT;
use InvalidArgumentException; use InvalidArgumentException;
use RuntimeException; use RuntimeException;
use UnexpectedValueException; use UnexpectedValueException;
use Index\{UriBase64,XDateTime}; use Index\{UriBase64,XDateTime};
use phpseclib3\Crypt\Common\{AsymmetricKey,PrivateKey,PublicKey};
// based on https://github.com/firebase/php-jwt/blob/8f718f4dfc9c5d5f0c994cdfd103921b43592712/src/JWT.php // based on https://github.com/firebase/php-jwt/blob/8f718f4dfc9c5d5f0c994cdfd103921b43592712/src/JWT.php
class JWT { class JWT {
public static function encode( public static function encode(
array|object $payload, array|object $payload,
JWTKey|(AsymmetricKey&PrivateKey)|string $key, JWK $key,
?string $keyId = null,
?string $alg = null, ?string $alg = null,
array|object|null $header = null array|object|null $header = null
): string { ): 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']; $head = ['typ' => 'JWT'];
if($header !== null) if($header !== null)
$head = array_merge($head, (array)$header); $head = array_merge($head, (array)$header);
$head['alg'] = $key->algo; $head['alg'] = $alg ?? $key->algo;
if($keyId !== null) if($head['alg'] === null)
$head['kid'] = $keyId; 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', 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; return $encoded;
} }
public static function decode( public static function decode(
string $token, string $token,
array|JWTKeySet|JWTKey|(AsymmetricKey&PrivateKey)|(AsymmetricKey&PublicKey)|string $keys, JWKSet $keys,
array|object|null $headers = null, array|object|null $headers = null,
?int $timestamp = null, ?int $timestamp = null,
int $leeway = 0, int $leeway = 0,
@ -44,14 +39,6 @@ class JWT {
): array|object { ): array|object {
$timestamp ??= time(); $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); $parts = explode('.', $token, 4);
if(count($parts) !== 3) if(count($parts) !== 3)
throw new InvalidArgumentException('wrong amount of segments'); throw new InvalidArgumentException('wrong amount of segments');
@ -80,7 +67,7 @@ class JWT {
$header['alg'] $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'); throw new RuntimeException('signature verification failed');
if(array_key_exists('nbf', $payload) && (is_int($payload['nbf']) || is_float($payload['nbf']))) { if(array_key_exists('nbf', $payload) && (is_int($payload['nbf']) || is_float($payload['nbf']))) {

51
src/JWT/SecLibJWK.php Normal file
View 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
View 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();

View file

@ -35,7 +35,6 @@ class MisuzuContext {
public private(set) Forum\ForumContext $forumCtx; public private(set) Forum\ForumContext $forumCtx;
public private(set) Messages\MessagesContext $messagesCtx; public private(set) Messages\MessagesContext $messagesCtx;
public private(set) OAuth2\OAuth2Context $oauth2Ctx; public private(set) OAuth2\OAuth2Context $oauth2Ctx;
public private(set) OpenID\OpenIDContext $openIdCtx;
public private(set) Profile\ProfileContext $profileCtx; public private(set) Profile\ProfileContext $profileCtx;
public private(set) Users\UsersContext $usersCtx; public private(set) Users\UsersContext $usersCtx;
public private(set) Redirects\RedirectsContext $redirectsCtx; 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->forumCtx = $this->deps->constructLazy(Forum\ForumContext::class));
$this->deps->register($this->messagesCtx = $this->deps->constructLazy(Messages\MessagesContext::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->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->profileCtx = $this->deps->constructLazy(Profile\ProfileContext::class));
$this->deps->register($this->usersCtx = $this->deps->constructLazy(Users\UsersContext::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'))); $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\OAuth2ApiRoutes::class));
$routingCtx->register($this->deps->constructLazy(OAuth2\OAuth2WebRoutes::class)); $routingCtx->register($this->deps->constructLazy(OAuth2\OAuth2WebRoutes::class));
$rpcServer->register($this->deps->constructLazy(OAuth2\OAuth2RpcHandler::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(WebFinger\WebFingerRoutes::class));
$routingCtx->register($this->deps->constructLazy(Forum\ForumCategoriesRoutes::class)); $routingCtx->register($this->deps->constructLazy(Forum\ForumCategoriesRoutes::class));

View file

@ -8,6 +8,8 @@ use Index\Http\Routing\{HttpGet,HttpOptions,HttpPost,RouteHandler,RouteHandlerCo
use Index\Urls\{UrlFormat,UrlRegistry,UrlSource,UrlSourceCommon}; use Index\Urls\{UrlFormat,UrlRegistry,UrlSource,UrlSourceCommon};
use Misuzu\SiteInfo; use Misuzu\SiteInfo;
use Misuzu\Apps\AppsContext; use Misuzu\Apps\AppsContext;
use Misuzu\Profile\ProfileContext;
use Misuzu\Users\UsersContext;
final class OAuth2ApiRoutes implements RouteHandler, UrlSource { final class OAuth2ApiRoutes implements RouteHandler, UrlSource {
use RouteHandlerCommon, UrlSourceCommon; use RouteHandlerCommon, UrlSourceCommon;
@ -15,6 +17,8 @@ final class OAuth2ApiRoutes implements RouteHandler, UrlSource {
public function __construct( public function __construct(
private OAuth2Context $oauth2Ctx, private OAuth2Context $oauth2Ctx,
private AppsContext $appsCtx, private AppsContext $appsCtx,
private UsersContext $usersCtx,
private ProfileContext $profileCtx,
private UrlRegistry $urls, private UrlRegistry $urls,
private SiteInfo $siteInfo, 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{ * @return array{
* device_code: string, * device_code: string,
@ -278,4 +323,120 @@ final class OAuth2ApiRoutes implements RouteHandler, UrlSource {
'error_description' => 'Requested grant type is not supported by this server.', '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;
}
} }

View file

@ -4,24 +4,31 @@ namespace Misuzu\OAuth2;
use RuntimeException; use RuntimeException;
use Index\Config\Config; use Index\Config\Config;
use Index\Db\DbConnection; use Index\Db\DbConnection;
use Misuzu\SiteInfo;
use Misuzu\Apps\{AppsContext,AppInfo}; use Misuzu\Apps\{AppsContext,AppInfo};
use Misuzu\OpenID\OpenIDContext; use Misuzu\JWT\JWT;
use Misuzu\Users\UserInfo; use Misuzu\Users\UserInfo;
class OAuth2Context { class OAuth2Context {
public private(set) OAuth2AuthorisationData $authorisations; public private(set) OAuth2AuthorisationData $authorisations;
public private(set) OAuth2TokensData $tokens; public private(set) OAuth2TokensData $tokens;
public private(set) OAuth2DevicesData $devices; public private(set) OAuth2DevicesData $devices;
public private(set) OAuth2Keys $keys;
public function __construct( public function __construct(
private Config $config, private Config $config,
DbConnection $dbConn, DbConnection $dbConn,
public private(set) AppsContext $appsCtx, public private(set) AppsContext $appsCtx,
private OpenIDContext $openIdCtx, private SiteInfo $siteInfo,
) { ) {
$this->authorisations = new OAuth2AuthorisationData($dbConn); $this->authorisations = new OAuth2AuthorisationData($dbConn);
$this->tokens = new OAuth2TokensData($dbConn); $this->tokens = new OAuth2TokensData($dbConn);
$this->devices = new OAuth2DevicesData($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 } */ /** @return string|array{ error: string, error_description: string } */
public function checkAndBuildScopeString( public function checkAndBuildScopeString(
AppInfo $appInfo, AppInfo $appInfo,
@ -169,7 +216,7 @@ class OAuth2Context {
$result['refresh_token'] = $refreshInfo->token; $result['refresh_token'] = $refreshInfo->token;
if(in_array('openid', $accessInfo->scopes)) if(in_array('openid', $accessInfo->scopes))
$result['id_token'] = $this->openIdCtx->createIdToken($appInfo, $accessInfo); $result['id_token'] = $this->createIdToken($appInfo, $accessInfo);
return $result; return $result;
} }

96
src/OAuth2/OAuth2Keys.php Normal file
View 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;
}
}

View file

@ -9,7 +9,6 @@ use Index\Http\Routing\{HttpGet,HttpPost,RouteHandler,RouteHandlerCommon};
use Index\Urls\{UrlFormat,UrlRegistry,UrlSource,UrlSourceCommon}; use Index\Urls\{UrlFormat,UrlRegistry,UrlSource,UrlSourceCommon};
use Misuzu\{CSRF,Template}; use Misuzu\{CSRF,Template};
use Misuzu\Auth\AuthInfo; use Misuzu\Auth\AuthInfo;
use Misuzu\OpenID\OpenIDContext;
use Misuzu\Users\UsersContext; use Misuzu\Users\UsersContext;
final class OAuth2WebRoutes implements RouteHandler, UrlSource { final class OAuth2WebRoutes implements RouteHandler, UrlSource {
@ -20,7 +19,6 @@ final class OAuth2WebRoutes implements RouteHandler, UrlSource {
public function __construct( public function __construct(
private OAuth2Context $oauth2Ctx, private OAuth2Context $oauth2Ctx,
private OpenIDContext $openIdCtx,
private UsersContext $usersCtx, private UsersContext $usersCtx,
private UrlRegistry $urls, private UrlRegistry $urls,
private AuthInfo $authInfo, private AuthInfo $authInfo,
@ -138,7 +136,7 @@ final class OAuth2WebRoutes implements RouteHandler, UrlSource {
]; ];
if(in_array('id_token', $responseTypes)) if(in_array('id_token', $responseTypes))
$result['id_token'] = $this->openIdCtx->createIdToken($appInfo, $authsInfo); $result['id_token'] = $this->oauth2Ctx->createIdToken($appInfo, $authsInfo);
return $result; return $result;
} }

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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