misuzu/src/OAuth2/OAuth2ApiRoutes.php
2025-03-24 22:15:52 +00:00

480 lines
20 KiB
PHP

<?php
namespace Misuzu\OAuth2;
use RuntimeException;
use Index\XArray;
use Index\Colour\{Colour,ColourRgb};
use Index\Http\{FormHttpContent,HttpResponseBuilder,HttpRequest};
use Index\Http\Routing\{HttpGet,HttpOptions,HttpPost,RouteHandler,RouteHandlerCommon};
use Index\Urls\{UrlFormat,UrlRegistry,UrlSource,UrlSourceCommon};
use Misuzu\SiteInfo;
use Misuzu\Apps\AppsContext;
use Misuzu\Auth\AuthInfo;
use Misuzu\Profile\ProfileContext;
use Misuzu\Users\UsersContext;
final class OAuth2ApiRoutes implements RouteHandler, UrlSource {
use RouteHandlerCommon, UrlSourceCommon;
public function __construct(
private OAuth2Context $oauth2Ctx,
private AppsContext $appsCtx,
private UsersContext $usersCtx,
private ProfileContext $profileCtx,
private UrlRegistry $urls,
private SiteInfo $siteInfo,
private AuthInfo $authInfo,
) {}
/**
* @param array<string, scalar> $result
* @return array<string, scalar>
*/
private static function filter(
HttpResponseBuilder $response,
HttpRequest $request,
array $result,
bool $authzHeader = false
): array {
if(array_key_exists('error', $result)) {
if($authzHeader) {
$wwwAuth = sprintf('Basic realm="%s"', (string)$request->getHeaderLine('Host'));
foreach($result as $name => $value)
$wwwAuth .= sprintf(', %s="%s"', $name, rawurlencode($value));
$response->statusCode = 401;
$response->setHeader('WWW-Authenticate', $wwwAuth);
} else
$response->statusCode = 400;
}
return $result;
}
/**
* @return int|array{
* resource: string,
* authorization_servers: string[],
* scopes_supported: string[],
* bearer_methods_supported: string[],
* }
*/
#[HttpOptions('/.well-known/oauth-protected-resource')]
#[HttpGet('/.well-known/oauth-protected-resource')]
public function getWellKnownProtectedResource(HttpResponseBuilder $response, HttpRequest $request): array|int {
$response->setHeader('Access-Control-Allow-Origin', '*');
if($request->method === 'OPTIONS')
return 204;
return [
'resource' => $this->siteInfo->url,
'authorization_servers' => [$this->siteInfo->url],
'scopes_supported' => [],
'bearer_methods_supported' => ['header'],
];
}
/**
* @return int|array{
* issuer: string,
* authorization_endpoint: string,
* token_endpoint: string,
* jwks_uri: string,
* scopes_supported: string[],
* response_types_supported: string[],
* response_modes_supported: string[],
* grant_types_supported: string[],
* token_endpoint_auth_methods_supported: string[],
* revocation_endpoint?: string,
* revocation_endpoint_auth_methods_supported?: string[],
* introspection_endpoint?: string,
* introspection_endpoint_auth_methods_supported?: string[],
* code_challenge_methods_supported: string[],
* }
*/
#[HttpOptions('/.well-known/oauth-authorization-server')]
#[HttpGet('/.well-known/oauth-authorization-server')]
public function getWellKnownAuthorizationServer(HttpResponseBuilder $response, HttpRequest $request): array|int {
$response->setHeader('Access-Control-Allow-Origin', '*');
if($request->method === 'OPTIONS')
return 204;
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')),
'jwks_uri' => sprintf('%s%s', $this->siteInfo->url, $this->urls->format('oauth2-jwks')),
'protected_resources' => [$this->siteInfo->url],
'scopes_supported' => XArray::select(
$this->appsCtx->scopes->getScopes(
restricted: false,
deprecated: false,
),
fn($scopeInfo) => $scopeInfo->string
),
'response_types_supported' => ['code', 'code id_token'],
'response_modes_supported' => ['query'],
'grant_types_supported' => [
'authorization_code',
'client_credentials',
'refresh_token',
'urn:ietf:params:oauth:grant-type:device_code',
],
'token_endpoint_auth_methods_supported' => [
'none',
'client_secret_basic',
'client_secret_post',
],
//'revocation_endpoint' => 'TODO: implement this',
//'revocation_endpoint_auth_methods_supported' => ['client_secret_basic'],
//'introspection_endpoint ' => 'TODO: implement this',
//'introspection_endpoint_auth_methods_supported' => ['Bearer'],
'code_challenge_methods_supported' => ['plain', 'S256'],
];
}
/**
* @return int|array{
* issuer: string,
* authorization_endpoint: string,
* token_endpoint: string,
* userinfo_endpoint: string,
* jwks_uri: string,
* response_types_supported: string[],
* response_modes_supported: string[],
* grant_types_supported: string[],
* subject_types_supported: string[],
* id_token_signing_alg_values_supported: string[],
* token_endpoint_auth_methods_supported: string[],
* }
*/
#[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',
//'client_credentials', <-- supported but makes NO sense for openid
'refresh_token',
'urn:ietf:params:oauth:grant-type:device_code',
],
'subject_types_supported' => ['public'],
'id_token_signing_alg_values_supported' => $signingAlgs,
'token_endpoint_auth_methods_supported' => ['none', 'client_secret_basic', 'client_secret_post'],
];
}
/** @return int|array{keys?: array<string, string>} */
#[HttpOptions('/oauth2/jwks.json')]
#[HttpGet('/oauth2/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,
* user_code: string,
* verification_uri: string,
* verification_uri_complete: string,
* expires_in?: int,
* interval?: int
* }|array{ error: string, error_description: string }
*/
#[HttpPost('/oauth2/request-authorise')]
#[HttpPost('/oauth2/request-authorize')]
#[UrlFormat('oauth2-request-authorise', '/oauth2/request-authorize')]
public function postRequestAuthorise(HttpResponseBuilder $response, HttpRequest $request): array {
$response->setHeader('Cache-Control', 'no-store');
if(!($request->content instanceof FormHttpContent))
return self::filter($response, $request, [
'error' => 'invalid_request',
'error_description' => 'Your request must use content type application/x-www-form-urlencoded.',
]);
$authzHeader = explode(' ', (string)$request->getHeaderLine('Authorization'));
if(strcasecmp($authzHeader[0], 'Basic') === 0) {
$authzHeader = explode(':', base64_decode($authzHeader[1] ?? ''));
$clientId = $authzHeader[0];
$clientSecret = $authzHeader[1] ?? '';
} elseif($authzHeader[0] !== '') {
return self::filter($response, $request, [
'error' => 'invalid_client',
'error_description' => 'You must use the Basic method for Authorization parameters.',
], authzHeader: true);
} else {
$clientId = (string)$request->content->getParam('client_id');
$clientSecret = '';
}
try {
$appInfo = $this->oauth2Ctx->appsCtx->apps->getAppInfo(clientId: $clientId, deleted: false);
} catch(RuntimeException $ex) {
return self::filter($response, $request, [
'error' => 'invalid_client',
'error_description' => 'No application has been registered with this client ID.',
], authzHeader: $authzHeader[0] !== '');
}
if($clientSecret !== '') {
// TODO: rate limiting
if(!$appInfo->verifyClientSecret($clientSecret))
return self::filter($response, $request, [
'error' => 'invalid_client',
'error_description' => 'Provided client secret is not correct for this application.',
], authzHeader: true);
}
return self::filter($response, $request, $this->oauth2Ctx->createDeviceAuthorisationRequest(
$appInfo,
$request->content->hasParam('scope') ? (string)$request->content->getParam('scope') : null
));
}
/**
* @return int|array{
* access_token: string,
* token_type: 'Bearer',
* expires_in?: int,
* scope?: string,
* refresh_token?: string,
* }|array{ error: string, error_description: string }
*/
#[HttpOptions('/oauth2/token')]
#[HttpPost('/oauth2/token')]
#[UrlFormat('oauth2-token', '/oauth2/token')]
public function postToken(HttpResponseBuilder $response, HttpRequest $request): array|int {
$response->setHeader('Cache-Control', 'no-store');
$originHeaders = ['Origin', 'X-Origin', 'Referer'];
$origins = [];
foreach($originHeaders as $originHeader) {
$originHeader = $request->getHeaderFirstLine($originHeader);
if($originHeader !== '' && !in_array($originHeader, $origins))
$origins[] = $originHeader;
}
if(!empty($origins)) {
// TODO: check if none of the provided origins is on a blocklist or something
// different origins being specified for each header should probably also be considered suspect...
$response->setHeader('Access-Control-Allow-Origin', $origins[0]);
$response->setHeader('Access-Control-Allow-Methods', 'OPTIONS, POST');
$response->setHeader('Access-Control-Allow-Headers', 'Authorization');
$response->setHeader('Access-Control-Expose-Headers', 'Vary');
foreach($originHeaders as $originHeader)
$response->setHeader('Vary', $originHeader);
}
if($request->method === 'OPTIONS')
return 204;
if(!($request->content instanceof FormHttpContent))
return self::filter($response, $request, [
'error' => 'invalid_request',
'error_description' => 'Your request must use content type application/x-www-form-urlencoded.',
]);
// authz header should be the preferred method
$authzHeader = explode(' ', (string)$request->getHeaderLine('Authorization'));
if(strcasecmp($authzHeader[0], 'Basic') === 0) {
$authzHeader = explode(':', base64_decode($authzHeader[1] ?? ''));
$clientId = $authzHeader[0];
$clientSecret = $authzHeader[1] ?? '';
} elseif($authzHeader[0] !== '') {
return self::filter($response, $request, [
'error' => 'invalid_client',
'error_description' => 'You must either use the Basic method for Authorization or use the client_id and client_secret parameters.',
], authzHeader: true);
} else {
$clientId = (string)$request->content->getParam('client_id');
$clientSecret = (string)$request->content->getParam('client_secret');
}
try {
$appInfo = $this->oauth2Ctx->appsCtx->apps->getAppInfo(clientId: $clientId, deleted: false);
} catch(RuntimeException $ex) {
return self::filter($response, $request, [
'error' => 'invalid_client',
'error_description' => 'No application has been registered with this client ID.',
], authzHeader: $authzHeader[0] !== '');
}
$isAuthed = false;
if($clientSecret !== '') {
// TODO: rate limiting
$isAuthed = $appInfo->verifyClientSecret($clientSecret);
if(!$isAuthed)
return self::filter($response, $request, [
'error' => 'invalid_client',
'error_description' => 'Provided client secret is not correct for this application.',
], authzHeader: $authzHeader[0] !== '');
}
$type = (string)$request->content->getParam('grant_type');
if($type === 'authorization_code')
return self::filter($response, $request, $this->oauth2Ctx->redeemAuthorisationCode(
$appInfo,
$isAuthed,
(string)$request->content->getParam('code'),
(string)$request->content->getParam('code_verifier')
));
if($type === 'refresh_token')
return self::filter($response, $request, $this->oauth2Ctx->redeemRefreshToken(
$appInfo,
$isAuthed,
(string)$request->content->getParam('refresh_token'),
$request->content->hasParam('scope') ? (string)$request->content->getParam('scope') : null
));
if($type === 'client_credentials')
return self::filter($response, $request, $this->oauth2Ctx->redeemClientCredentials(
$appInfo,
$isAuthed,
$request->content->hasParam('scope') ? (string)$request->content->getParam('scope') : null
));
if($type === 'urn:ietf:params:oauth:grant-type:device_code' || $type === 'device_code')
return self::filter($response, $request, $this->oauth2Ctx->redeemDeviceCode(
$appInfo,
$isAuthed,
(string)$request->content->getParam('device_code')
));
return self::filter($response, $request, [
'error' => 'unsupported_grant_type',
'error_description' => 'Requested grant type is not supported by this server.',
]);
}
/**
* @return int|array{
* sub: string,
* name?: string,
* nickname?: string,
* preferred_username?: string,
* profile?: string,
* picture?: string,
* zoneinfo?: string,
* birthdate?: string,
* website?: string,
* 'http://railgun.sh/country_code'?: string,
* 'http://railgun.sh/colour_raw'?: int,
* 'http://railgun.sh/colour_css'?: string,
* 'http://railgun.sh/rank'?: int,
* 'http://railgun.sh/banned'?: bool,
* 'http://railgun.sh/roles'?: string[],
* 'http://railgun.sh/is_super'?: bool,
* email?: string,
* email_verified?: bool,
* }|array{ error: string, error_description: string }
*/
#[HttpOptions('/oauth2/userinfo')]
#[HttpGet('/oauth2/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;
if(!$this->authInfo->loggedInBearer) {
$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.',
];
}
if(!$this->authInfo->hasScope('openid')) {
$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.',
];
}
$result = ['sub' => $userInfo->id];
if($this->authInfo->hasScope('profile')) {
$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($this->authInfo->hasScope('email')) {
$result['email'] = $userInfo->emailAddress;
$result['email_verified'] = true;
}
return $result;
}
}